Move app/src (mostly) to flipper-ui-core/src
Summary: This diff moves all UI code from app/src to app/flipper-ui-core. That is now slightly too much (e.g. node deps are not removed yet), but from here it should be easier to move things out again, as I don't want this diff to be open for too long to avoid too much merge conflicts. * But at least flipper-ui-core is Electron free :) * Killed all cross module imports as well, as they where now even more in the way * Some unit test needed some changes, most not too big (but emotion hashes got renumbered in the snapshots, feel free to ignore that) * Found some files that were actually meaningless (tsconfig in plugins, WatchTools files, that start generating compile errors, removed those Follow up work: * make flipper-ui-core configurable, and wire up flipper-server-core in Electron instead of here * remove node deps (aigoncharov) * figure out correct place to load GKs, plugins, make intern requests etc., and move to the correct module * clean up deps Reviewed By: aigoncharov Differential Revision: D32427722 fbshipit-source-id: 14fe92e1ceb15b9dcf7bece367c8ab92df927a70
This commit is contained in:
committed by
Facebook GitHub Bot
parent
54b7ce9308
commit
7e50c0466a
153
desktop/flipper-ui-core/src/chrome/ChangelogSheet.tsx
Normal file
153
desktop/flipper-ui-core/src/chrome/ChangelogSheet.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Markdown} from '../ui';
|
||||
import {readFileSync} from 'fs';
|
||||
import React, {Component} from 'react';
|
||||
import path from 'path';
|
||||
import {reportUsage} from 'flipper-common';
|
||||
import {getChangelogPath} from '../utils/pathUtils';
|
||||
import {Modal} from 'antd';
|
||||
import {theme} from 'flipper-plugin';
|
||||
|
||||
const changelogKey = 'FlipperChangelogStatus';
|
||||
|
||||
type ChangelogStatus = {
|
||||
lastHeader: string;
|
||||
};
|
||||
|
||||
let getChangelogFromDisk = (): string => {
|
||||
const changelogFromDisk: string = readFileSync(
|
||||
path.join(getChangelogPath(), 'CHANGELOG.md'),
|
||||
'utf8',
|
||||
).trim();
|
||||
|
||||
getChangelogFromDisk = () => changelogFromDisk;
|
||||
return changelogFromDisk;
|
||||
};
|
||||
|
||||
const changelogSectionStyle = {
|
||||
padding: 10,
|
||||
maxHeight: '60vh',
|
||||
overflow: 'scroll',
|
||||
marginBottom: 10,
|
||||
background: theme.backgroundDefault,
|
||||
borderRadius: 4,
|
||||
width: '100%',
|
||||
};
|
||||
|
||||
type Props = {
|
||||
onHide: () => void;
|
||||
recent?: boolean;
|
||||
};
|
||||
|
||||
export default class ChangelogSheet extends Component<Props, {}> {
|
||||
componentDidMount() {
|
||||
if (!this.props.recent) {
|
||||
// opened through the menu
|
||||
reportUsage('changelog:opened');
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.props.recent) {
|
||||
markChangelogRead(window.localStorage, getChangelogFromDisk());
|
||||
}
|
||||
if (!this.props.recent) {
|
||||
reportUsage('changelog:closed');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
title="Changelog"
|
||||
onCancel={this.props.onHide}
|
||||
footer={null}>
|
||||
<Markdown
|
||||
source={
|
||||
this.props.recent
|
||||
? getRecentChangelog(window.localStorage, getChangelogFromDisk())
|
||||
: getChangelogFromDisk()
|
||||
}
|
||||
style={changelogSectionStyle}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getChangelogStatus(
|
||||
localStorage: Storage,
|
||||
): ChangelogStatus | undefined {
|
||||
return JSON.parse(localStorage.getItem(changelogKey) || '{}');
|
||||
}
|
||||
|
||||
function getFirstHeader(changelog: string): string {
|
||||
const match = changelog.match(/(^|\n)(#.*?)\n/);
|
||||
if (match) {
|
||||
return match[2];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function hasNewChangesToShow(
|
||||
localStorage: Storage | undefined,
|
||||
changelog: string = getChangelogFromDisk(),
|
||||
): boolean {
|
||||
if (!localStorage) {
|
||||
return false;
|
||||
}
|
||||
const status = getChangelogStatus(localStorage);
|
||||
if (!status || !status.lastHeader) {
|
||||
return true;
|
||||
}
|
||||
const firstHeader = getFirstHeader(changelog);
|
||||
if (firstHeader && firstHeader !== status.lastHeader) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export /*for test*/ function getRecentChangelog(
|
||||
localStorage: Storage | undefined,
|
||||
changelog: string,
|
||||
): string {
|
||||
if (!localStorage) {
|
||||
return 'Changelog not available';
|
||||
}
|
||||
const status = getChangelogStatus(localStorage);
|
||||
if (!status || !status.lastHeader) {
|
||||
return changelog.trim();
|
||||
}
|
||||
const lastHeaderIndex = changelog.indexOf(status.lastHeader);
|
||||
if (lastHeaderIndex === -1) {
|
||||
return changelog.trim();
|
||||
} else {
|
||||
return changelog.substr(0, lastHeaderIndex).trim();
|
||||
}
|
||||
}
|
||||
|
||||
export /*for test*/ function markChangelogRead(
|
||||
localStorage: Storage | undefined,
|
||||
changelog: string,
|
||||
) {
|
||||
if (!localStorage) {
|
||||
return;
|
||||
}
|
||||
const firstHeader = getFirstHeader(changelog);
|
||||
if (!firstHeader) {
|
||||
return;
|
||||
}
|
||||
const status: ChangelogStatus = {
|
||||
lastHeader: firstHeader,
|
||||
};
|
||||
localStorage.setItem(changelogKey, JSON.stringify(status));
|
||||
}
|
||||
144
desktop/flipper-ui-core/src/chrome/ConsoleLogs.tsx
Normal file
144
desktop/flipper-ui-core/src/chrome/ConsoleLogs.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import React from 'react';
|
||||
import {Console, Hook} from 'console-feed';
|
||||
import type {Methods} from 'console-feed/lib/definitions/Methods';
|
||||
import type {Styles} from 'console-feed/lib/definitions/Styles';
|
||||
import {createState, useValue} from 'flipper-plugin';
|
||||
import {useLocalStorageState} from 'flipper-plugin';
|
||||
import {theme, Toolbar, Layout} from 'flipper-plugin';
|
||||
import {useIsDarkMode} from '../utils/useIsDarkMode';
|
||||
import {Button, Dropdown, Menu, Checkbox} from 'antd';
|
||||
import {DownOutlined} from '@ant-design/icons';
|
||||
import {DeleteOutlined} from '@ant-design/icons';
|
||||
|
||||
const MAX_LOG_ITEMS = 1000;
|
||||
|
||||
export const logsAtom = createState<any[]>([]);
|
||||
export const errorCounterAtom = createState(0);
|
||||
|
||||
export function enableConsoleHook() {
|
||||
Hook(
|
||||
window.console,
|
||||
(log) => {
|
||||
if (log.method === 'debug') {
|
||||
return; // See below, skip debug messages which are generated very aggressively by Flipper
|
||||
}
|
||||
const newLogs = logsAtom.get().slice(-MAX_LOG_ITEMS);
|
||||
newLogs.push(log);
|
||||
logsAtom.set(newLogs);
|
||||
if (log.method === 'error' || log.method === 'assert') {
|
||||
errorCounterAtom.set(errorCounterAtom.get() + 1);
|
||||
}
|
||||
},
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
function clearLogs() {
|
||||
logsAtom.set([]);
|
||||
errorCounterAtom.set(0);
|
||||
}
|
||||
|
||||
const allLogLevels: Methods[] = [
|
||||
'log',
|
||||
// 'debug', We typically don't want to allow users to enable the debug logs, as they are used very intensively by flipper itself,
|
||||
// making Flipper / console-feed. For debug level logging, use the Chrome devtools.
|
||||
'info',
|
||||
'warn',
|
||||
'error',
|
||||
'table',
|
||||
'clear',
|
||||
'time',
|
||||
'timeEnd',
|
||||
'count',
|
||||
'assert',
|
||||
];
|
||||
|
||||
const defaultLogLevels: Methods[] = ['warn', 'error', 'table', 'assert'];
|
||||
|
||||
export function ConsoleLogs() {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const logs = useValue(logsAtom);
|
||||
const [logLevels, setLogLevels] = useLocalStorageState<Methods[]>(
|
||||
'console-logs-loglevels',
|
||||
defaultLogLevels,
|
||||
);
|
||||
|
||||
const styles = useMemo(buildTheme, []);
|
||||
|
||||
return (
|
||||
<Layout.Top>
|
||||
<Toolbar wash>
|
||||
<Button onClick={clearLogs} icon={<DeleteOutlined />}>
|
||||
Clear Logs
|
||||
</Button>
|
||||
<Dropdown
|
||||
overlay={
|
||||
<Menu>
|
||||
{allLogLevels.map((l) => (
|
||||
<Menu.Item
|
||||
key={l}
|
||||
onClick={() => {
|
||||
setLogLevels((state) =>
|
||||
state.includes(l)
|
||||
? state.filter((level) => level !== l)
|
||||
: [l, ...state],
|
||||
);
|
||||
}}>
|
||||
<Checkbox checked={logLevels.includes(l)}>{l}</Checkbox>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
}>
|
||||
<Button>
|
||||
Log Levels
|
||||
<DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Toolbar>
|
||||
<Layout.ScrollContainer vertical>
|
||||
<Console
|
||||
logs={logs}
|
||||
filter={logLevels}
|
||||
variant={isDarkMode ? 'dark' : 'light'}
|
||||
styles={styles}
|
||||
/>
|
||||
</Layout.ScrollContainer>
|
||||
</Layout.Top>
|
||||
);
|
||||
}
|
||||
|
||||
function buildTheme(): Styles {
|
||||
return {
|
||||
// See: https://github.com/samdenty/console-feed/blob/master/src/definitions/Styles.d.ts
|
||||
BASE_BACKGROUND_COLOR: 'transparent',
|
||||
BASE_COLOR: theme.textColorPrimary,
|
||||
LOG_COLOR: theme.textColorPrimary,
|
||||
LOG_BACKGROUND: 'transparent',
|
||||
LOG_INFO_BACKGROUND: 'transparent',
|
||||
LOG_COMMAND_BACKGROUND: 'transparent',
|
||||
LOG_RESULT_BACKGROUND: 'transparent',
|
||||
LOG_WARN_BACKGROUND: theme.warningColor,
|
||||
LOG_ERROR_BACKGROUND: theme.errorColor,
|
||||
LOG_INFO_COLOR: theme.textColorPrimary,
|
||||
LOG_COMMAND_COLOR: theme.textColorSecondary,
|
||||
LOG_RESULT_COLOR: theme.textColorSecondary,
|
||||
LOG_WARN_COLOR: 'white',
|
||||
LOG_ERROR_COLOR: 'white',
|
||||
LOG_INFO_BORDER: theme.dividerColor,
|
||||
LOG_COMMAND_BORDER: theme.dividerColor,
|
||||
LOG_RESULT_BORDER: theme.dividerColor,
|
||||
LOG_WARN_BORDER: theme.dividerColor,
|
||||
LOG_ERROR_BORDER: theme.dividerColor,
|
||||
LOG_BORDER: theme.dividerColor,
|
||||
};
|
||||
}
|
||||
420
desktop/flipper-ui-core/src/chrome/DoctorSheet.tsx
Normal file
420
desktop/flipper-ui-core/src/chrome/DoctorSheet.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* 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,
|
||||
styled,
|
||||
Text,
|
||||
FlexRow,
|
||||
Glyph,
|
||||
LoadingIndicator,
|
||||
colors,
|
||||
Spacer,
|
||||
Button,
|
||||
FlexBox,
|
||||
Checkbox,
|
||||
} from '../ui';
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {State as Store} from '../reducers';
|
||||
import {
|
||||
HealthcheckResult,
|
||||
HealthcheckReportCategory,
|
||||
HealthcheckReport,
|
||||
startHealthchecks,
|
||||
finishHealthchecks,
|
||||
updateHealthcheckResult,
|
||||
acknowledgeProblems,
|
||||
resetAcknowledgedProblems,
|
||||
} from '../reducers/healthchecks';
|
||||
import runHealthchecks, {
|
||||
HealthcheckSettings,
|
||||
HealthcheckEventsHandler,
|
||||
} from '../utils/runHealthchecks';
|
||||
import {getFlipperLib} from 'flipper-plugin';
|
||||
import {reportUsage} from 'flipper-common';
|
||||
|
||||
type StateFromProps = {
|
||||
healthcheckReport: HealthcheckReport;
|
||||
} & HealthcheckSettings;
|
||||
|
||||
type DispatchFromProps = {
|
||||
acknowledgeProblems: () => void;
|
||||
resetAcknowledgedProblems: () => void;
|
||||
} & HealthcheckEventsHandler;
|
||||
|
||||
const Container = styled(FlexColumn)({
|
||||
padding: 20,
|
||||
width: 600,
|
||||
});
|
||||
|
||||
const HealthcheckDisplayContainer = styled(FlexRow)({
|
||||
alignItems: 'center',
|
||||
marginBottom: 5,
|
||||
});
|
||||
|
||||
const HealthcheckListContainer = styled(FlexColumn)({
|
||||
marginBottom: 20,
|
||||
width: 300,
|
||||
});
|
||||
|
||||
const Title = styled(Text)({
|
||||
marginBottom: 18,
|
||||
marginRight: 10,
|
||||
fontWeight: 100,
|
||||
fontSize: '40px',
|
||||
});
|
||||
|
||||
const CategoryContainer = styled(FlexColumn)({
|
||||
marginBottom: 5,
|
||||
marginLeft: 20,
|
||||
marginRight: 20,
|
||||
});
|
||||
|
||||
const SideContainer = styled(FlexBox)({
|
||||
marginBottom: 20,
|
||||
padding: 20,
|
||||
backgroundColor: colors.highlightBackground,
|
||||
border: '1px solid #b3b3b3',
|
||||
width: 250,
|
||||
});
|
||||
|
||||
const SideContainerText = styled(Text)({
|
||||
display: 'block',
|
||||
wordWrap: 'break-word',
|
||||
overflow: 'auto',
|
||||
});
|
||||
|
||||
const HealthcheckLabel = styled(Text)({
|
||||
paddingLeft: 5,
|
||||
});
|
||||
|
||||
const SkipReasonLabel = styled(Text)({
|
||||
paddingLeft: 21,
|
||||
fontStyle: 'italic',
|
||||
});
|
||||
|
||||
const CenteredContainer = styled.label({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
type OwnProps = {
|
||||
onHide: () => void;
|
||||
};
|
||||
|
||||
function CenteredCheckbox(props: {
|
||||
checked: boolean;
|
||||
text: string;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
const {checked, onChange, text} = props;
|
||||
return (
|
||||
<CenteredContainer>
|
||||
<Checkbox checked={checked} onChange={onChange} />
|
||||
{text}
|
||||
</CenteredContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function HealthcheckIcon(props: {checkResult: HealthcheckResult}) {
|
||||
const {checkResult: check} = props;
|
||||
switch (props.checkResult.status) {
|
||||
case 'IN_PROGRESS':
|
||||
return <LoadingIndicator size={16} title={props.checkResult.message} />;
|
||||
case 'SKIPPED':
|
||||
return (
|
||||
<Glyph
|
||||
size={16}
|
||||
name={'question'}
|
||||
color={colors.gray}
|
||||
title={props.checkResult.message}
|
||||
/>
|
||||
);
|
||||
case 'SUCCESS':
|
||||
return (
|
||||
<Glyph
|
||||
size={16}
|
||||
name={'checkmark'}
|
||||
color={colors.green}
|
||||
title={props.checkResult.message}
|
||||
/>
|
||||
);
|
||||
case 'FAILED':
|
||||
return (
|
||||
<Glyph
|
||||
size={16}
|
||||
name={'cross'}
|
||||
color={colors.red}
|
||||
title={props.checkResult.message}
|
||||
variant={check.isAcknowledged ? 'outline' : 'filled'}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Glyph
|
||||
size={16}
|
||||
name={'caution'}
|
||||
color={colors.yellow}
|
||||
title={props.checkResult.message}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function HealthcheckDisplay(props: {
|
||||
label: string;
|
||||
result: HealthcheckResult;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<FlexColumn shrink>
|
||||
<HealthcheckDisplayContainer shrink title={props.result.message}>
|
||||
<HealthcheckIcon checkResult={props.result} />
|
||||
<HealthcheckLabel
|
||||
bold={props.selected}
|
||||
underline={!!props.onClick}
|
||||
cursor={props.onClick && 'pointer'}
|
||||
onClick={props.onClick}>
|
||||
{props.label}
|
||||
</HealthcheckLabel>
|
||||
</HealthcheckDisplayContainer>
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
|
||||
function SideMessageDisplay(props: {children: React.ReactNode}) {
|
||||
return <SideContainerText selectable>{props.children}</SideContainerText>;
|
||||
}
|
||||
|
||||
function ResultMessage(props: {result: HealthcheckResult}) {
|
||||
if (status === 'IN_PROGRESS') {
|
||||
return <p>Doctor is running healthchecks...</p>;
|
||||
} else if (hasProblems(props.result)) {
|
||||
return (
|
||||
<p>
|
||||
Doctor has discovered problems with your installation. Please click to
|
||||
an item to get its details.
|
||||
</p>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<p>
|
||||
All good! Doctor has not discovered any issues with your installation.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hasProblems(result: HealthcheckResult) {
|
||||
const {status} = result;
|
||||
return status === 'FAILED' || status === 'WARNING';
|
||||
}
|
||||
|
||||
function hasNewProblems(result: HealthcheckResult) {
|
||||
return hasProblems(result) && !result.isAcknowledged;
|
||||
}
|
||||
|
||||
type State = {
|
||||
acknowledgeCheckboxVisible: boolean;
|
||||
acknowledgeOnClose?: boolean;
|
||||
selectedCheckKey?: string;
|
||||
};
|
||||
|
||||
type Props = OwnProps & StateFromProps & DispatchFromProps;
|
||||
class DoctorSheet extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
acknowledgeCheckboxVisible: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
reportUsage('doctor:report:opened');
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props: Props, state: State): State | null {
|
||||
if (
|
||||
!state.acknowledgeCheckboxVisible &&
|
||||
hasProblems(props.healthcheckReport.result)
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
acknowledgeCheckboxVisible: true,
|
||||
acknowledgeOnClose:
|
||||
state.acknowledgeOnClose === undefined
|
||||
? !hasNewProblems(props.healthcheckReport.result)
|
||||
: state.acknowledgeOnClose,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
state.acknowledgeCheckboxVisible &&
|
||||
!hasProblems(props.healthcheckReport.result)
|
||||
) {
|
||||
return {
|
||||
...state,
|
||||
acknowledgeCheckboxVisible: false,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
if (this.state.acknowledgeOnClose) {
|
||||
if (hasNewProblems(this.props.healthcheckReport.result)) {
|
||||
reportUsage('doctor:report:closed:newProblems:acknowledged');
|
||||
}
|
||||
reportUsage('doctor:report:closed:acknowleged');
|
||||
this.props.acknowledgeProblems();
|
||||
} else {
|
||||
if (hasNewProblems(this.props.healthcheckReport.result)) {
|
||||
reportUsage('doctor:report:closed:newProblems:notAcknowledged');
|
||||
}
|
||||
reportUsage('doctor:report:closed:notAcknowledged');
|
||||
this.props.resetAcknowledgedProblems();
|
||||
}
|
||||
}
|
||||
|
||||
onAcknowledgeOnCloseChanged(acknowledge: boolean): void {
|
||||
this.setState((prevState) => {
|
||||
return {
|
||||
...prevState,
|
||||
acknowledgeOnClose: acknowledge,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
openHelpUrl(helpUrl?: string): void {
|
||||
helpUrl && getFlipperLib().openLink(helpUrl);
|
||||
}
|
||||
|
||||
async runHealthchecks(): Promise<void> {
|
||||
await runHealthchecks(this.props);
|
||||
}
|
||||
|
||||
getCheckMessage(checkKey: string): string {
|
||||
for (const cat of Object.values(this.props.healthcheckReport.categories)) {
|
||||
const check = Object.values(cat.checks).find(
|
||||
(chk) => chk.key === checkKey,
|
||||
);
|
||||
if (check) {
|
||||
return check.result.message || '';
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Container>
|
||||
<Title>Doctor</Title>
|
||||
<FlexRow>
|
||||
<HealthcheckListContainer>
|
||||
{Object.values(this.props.healthcheckReport.categories).map(
|
||||
(category: HealthcheckReportCategory) => {
|
||||
return (
|
||||
<CategoryContainer key={category.key}>
|
||||
<HealthcheckDisplay
|
||||
label={category.label}
|
||||
result={category.result}
|
||||
/>
|
||||
{category.result.status !== 'SKIPPED' && (
|
||||
<CategoryContainer>
|
||||
{Object.values(category.checks).map((check) => (
|
||||
<HealthcheckDisplay
|
||||
key={check.key}
|
||||
selected={check.key === this.state.selectedCheckKey}
|
||||
label={check.label}
|
||||
result={check.result}
|
||||
onClick={() =>
|
||||
this.setState({
|
||||
...this.state,
|
||||
selectedCheckKey:
|
||||
this.state.selectedCheckKey === check.key
|
||||
? undefined
|
||||
: check.key,
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</CategoryContainer>
|
||||
)}
|
||||
{category.result.status === 'SKIPPED' && (
|
||||
<CategoryContainer>
|
||||
<SkipReasonLabel>
|
||||
{category.result.message}
|
||||
</SkipReasonLabel>
|
||||
</CategoryContainer>
|
||||
)}
|
||||
</CategoryContainer>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</HealthcheckListContainer>
|
||||
<Spacer />
|
||||
<SideContainer shrink>
|
||||
<SideMessageDisplay>
|
||||
<SideContainerText selectable>
|
||||
{this.state.selectedCheckKey && (
|
||||
<p>{this.getCheckMessage(this.state.selectedCheckKey)}</p>
|
||||
)}
|
||||
{!this.state.selectedCheckKey && (
|
||||
<ResultMessage result={this.props.healthcheckReport.result} />
|
||||
)}
|
||||
</SideContainerText>
|
||||
</SideMessageDisplay>
|
||||
</SideContainer>
|
||||
</FlexRow>
|
||||
<FlexRow>
|
||||
<Spacer />
|
||||
{this.state.acknowledgeCheckboxVisible && (
|
||||
<CenteredCheckbox
|
||||
checked={!!this.state.acknowledgeOnClose}
|
||||
onChange={this.onAcknowledgeOnCloseChanged.bind(this)}
|
||||
text={
|
||||
'Do not show warning about these problems on Flipper startup'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Button compact padded onClick={this.props.onHide}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
disabled={
|
||||
this.props.healthcheckReport.result.status === 'IN_PROGRESS'
|
||||
}
|
||||
type="primary"
|
||||
compact
|
||||
padded
|
||||
onClick={() => this.runHealthchecks()}>
|
||||
Re-run
|
||||
</Button>
|
||||
</FlexRow>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
|
||||
({healthchecks: {healthcheckReport}, settingsState}) => ({
|
||||
healthcheckReport,
|
||||
settings: settingsState,
|
||||
}),
|
||||
{
|
||||
startHealthchecks,
|
||||
finishHealthchecks,
|
||||
updateHealthcheckResult,
|
||||
acknowledgeProblems,
|
||||
resetAcknowledgedProblems,
|
||||
},
|
||||
)(DoctorSheet);
|
||||
59
desktop/flipper-ui-core/src/chrome/ExportDataPluginSheet.tsx
Normal file
59
desktop/flipper-ui-core/src/chrome/ExportDataPluginSheet.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
import React, {Component} from 'react';
|
||||
import {State as Store} from '../reducers';
|
||||
import ListView from './ListView';
|
||||
import {FlexColumn, styled} from '../ui';
|
||||
import {getExportablePlugins} from '../selectors/connections';
|
||||
|
||||
type OwnProps = {
|
||||
onHide: () => void;
|
||||
selectedPlugins: Array<string>;
|
||||
setSelectedPlugins: (plugins: string[]) => void;
|
||||
};
|
||||
|
||||
type StateFromProps = {
|
||||
availablePluginsToExport: Array<{id: string; label: string}>;
|
||||
};
|
||||
|
||||
type Props = OwnProps & StateFromProps;
|
||||
|
||||
const Container = styled(FlexColumn)({
|
||||
maxHeight: 700,
|
||||
padding: 8,
|
||||
});
|
||||
|
||||
class ExportDataPluginSheet extends Component<Props, {}> {
|
||||
render() {
|
||||
return (
|
||||
<Container>
|
||||
<ListView
|
||||
type="multiple"
|
||||
title="Select the plugins for which you want to export the data"
|
||||
leftPadding={8}
|
||||
onChange={(selectedArray) => {
|
||||
this.props.setSelectedPlugins(selectedArray);
|
||||
}}
|
||||
elements={this.props.availablePluginsToExport}
|
||||
selectedElements={new Set(this.props.selectedPlugins)}
|
||||
onHide={() => {}}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect<StateFromProps, {}, OwnProps, Store>((state) => {
|
||||
const availablePluginsToExport = getExportablePlugins(state);
|
||||
return {
|
||||
availablePluginsToExport,
|
||||
};
|
||||
})(ExportDataPluginSheet);
|
||||
29
desktop/flipper-ui-core/src/chrome/FlipperDevTools.tsx
Normal file
29
desktop/flipper-ui-core/src/chrome/FlipperDevTools.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Layout} from '../ui';
|
||||
import React from 'react';
|
||||
import {Tab, Tabs} from 'flipper-plugin';
|
||||
import {ConsoleLogs} from './ConsoleLogs';
|
||||
import {FlipperMessages} from './FlipperMessages';
|
||||
|
||||
export function FlipperDevTools() {
|
||||
return (
|
||||
<Layout.Container grow>
|
||||
<Tabs grow>
|
||||
<Tab tab="Console">
|
||||
<ConsoleLogs />
|
||||
</Tab>
|
||||
<Tab tab="Messages">
|
||||
<FlipperMessages />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
205
desktop/flipper-ui-core/src/chrome/FlipperMessages.tsx
Normal file
205
desktop/flipper-ui-core/src/chrome/FlipperMessages.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
DataInspector,
|
||||
DataTable,
|
||||
DataTableColumn,
|
||||
Layout,
|
||||
createState,
|
||||
createDataSource,
|
||||
theme,
|
||||
styled,
|
||||
useValue,
|
||||
} from 'flipper-plugin';
|
||||
import {Button} from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
PauseCircleOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import React, {useCallback, useState} from 'react';
|
||||
|
||||
export type MessageInfo = {
|
||||
time?: Date;
|
||||
device?: string;
|
||||
app: string;
|
||||
flipperInternalMethod?: string;
|
||||
plugin?: string;
|
||||
pluginMethod?: string;
|
||||
payload?: any;
|
||||
direction:
|
||||
| 'toClient:call'
|
||||
| 'toClient:send'
|
||||
| 'toFlipper:message'
|
||||
| 'toFlipper:response';
|
||||
};
|
||||
|
||||
export interface MessageRow extends MessageInfo {
|
||||
time: Date;
|
||||
}
|
||||
|
||||
const Placeholder = styled(Layout.Container)({
|
||||
center: true,
|
||||
color: theme.textColorPlaceholder,
|
||||
fontSize: 18,
|
||||
});
|
||||
|
||||
function createRow(message: MessageInfo): MessageRow {
|
||||
return {
|
||||
...message,
|
||||
time: message.time == null ? new Date() : message.time,
|
||||
};
|
||||
}
|
||||
|
||||
const COLUMN_CONFIG: DataTableColumn<MessageRow>[] = [
|
||||
{
|
||||
key: 'time',
|
||||
title: 'Time',
|
||||
},
|
||||
{
|
||||
key: 'device',
|
||||
title: 'Device',
|
||||
},
|
||||
{
|
||||
key: 'app',
|
||||
title: 'App',
|
||||
},
|
||||
{
|
||||
key: 'flipperInternalMethod',
|
||||
title: 'Flipper Internal Method',
|
||||
},
|
||||
{
|
||||
key: 'plugin',
|
||||
title: 'Plugin',
|
||||
},
|
||||
{
|
||||
key: 'pluginMethod',
|
||||
title: 'Method',
|
||||
},
|
||||
{
|
||||
key: 'direction',
|
||||
title: 'Direction',
|
||||
},
|
||||
];
|
||||
|
||||
const flipperDebugMessages = createDataSource<MessageRow>([], {
|
||||
limit: 1024 * 10,
|
||||
persist: 'messages',
|
||||
});
|
||||
const flipperDebugMessagesEnabled = createState(false);
|
||||
|
||||
export function registerFlipperDebugMessage(message: MessageInfo) {
|
||||
if (flipperDebugMessagesEnabled.get()) {
|
||||
flipperDebugMessages.append(createRow(message));
|
||||
}
|
||||
}
|
||||
|
||||
export function isFlipperMessageDebuggingEnabled(): boolean {
|
||||
return flipperDebugMessagesEnabled.get();
|
||||
}
|
||||
|
||||
// exposed for testing
|
||||
export function setFlipperMessageDebuggingEnabled(value: boolean) {
|
||||
flipperDebugMessagesEnabled.set(value);
|
||||
}
|
||||
|
||||
// exposed for testing
|
||||
export function clearFlipperDebugMessages() {
|
||||
flipperDebugMessages.clear();
|
||||
}
|
||||
|
||||
// exposed for testing ONLY!
|
||||
export function getFlipperDebugMessages() {
|
||||
return flipperDebugMessages.records();
|
||||
}
|
||||
|
||||
function Sidebar({selection}: {selection: undefined | MessageRow}) {
|
||||
const renderExtra = (extra: any) => (
|
||||
<DataInspector data={extra} expandRoot={false} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout.ScrollContainer pad>
|
||||
{selection != null ? (
|
||||
renderExtra(selection.payload)
|
||||
) : (
|
||||
<Placeholder grow pad="large">
|
||||
Select a message to view details
|
||||
</Placeholder>
|
||||
)}
|
||||
</Layout.ScrollContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const PauseResumeButton = () => {
|
||||
const paused = !useValue(flipperDebugMessagesEnabled);
|
||||
|
||||
return (
|
||||
<Button
|
||||
title={`Click to enable tracing flipper messages`}
|
||||
danger={!paused}
|
||||
onClick={() => {
|
||||
flipperDebugMessagesEnabled.update((v) => !v);
|
||||
}}>
|
||||
{paused ? <PlayCircleOutlined /> : <PauseCircleOutlined />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export function FlipperMessages() {
|
||||
const [selection, setSelection] = useState<MessageRow | undefined>();
|
||||
const paused = !useValue(flipperDebugMessagesEnabled);
|
||||
|
||||
const clearTableButton = (
|
||||
<Button
|
||||
title="Clear logs"
|
||||
onClick={() => {
|
||||
clearFlipperDebugMessages();
|
||||
setSelection(undefined);
|
||||
}}>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
);
|
||||
|
||||
const renderEmpty = useCallback(
|
||||
() => (
|
||||
<Layout.Container center pad gap style={{width: '100%', marginTop: 200}}>
|
||||
{paused ? (
|
||||
<>
|
||||
Click to enable debugging Flipper messages between the Flipper
|
||||
application and connected clients: <PauseResumeButton />
|
||||
</>
|
||||
) : (
|
||||
'Waiting for data...'
|
||||
)}
|
||||
</Layout.Container>
|
||||
),
|
||||
[paused],
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout.Right resizable width={400}>
|
||||
<DataTable<MessageRow>
|
||||
dataSource={flipperDebugMessages}
|
||||
columns={COLUMN_CONFIG}
|
||||
onSelect={setSelection}
|
||||
enableAutoScroll
|
||||
onRenderEmpty={renderEmpty}
|
||||
extraActions={
|
||||
<>
|
||||
<PauseResumeButton />
|
||||
{clearTableButton}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<Sidebar selection={selection} />
|
||||
</Layout.Right>
|
||||
);
|
||||
}
|
||||
92
desktop/flipper-ui-core/src/chrome/FpsGraph.tsx
Normal file
92
desktop/flipper-ui-core/src/chrome/FpsGraph.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* 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, {useEffect, useRef} from 'react';
|
||||
import {fpsEmitter} from '../dispatcher/tracking';
|
||||
|
||||
const width = 36;
|
||||
const height = 36;
|
||||
const graphHeight = 20;
|
||||
|
||||
export default function FpsGraph({sampleRate = 200}: {sampleRate?: number}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fps: number[] = new Array<number>(width).fill(0, 0, width);
|
||||
let lastFps = 0;
|
||||
let lastDraw = Date.now();
|
||||
|
||||
const handler = (xfps: number) => {
|
||||
// at any interval, take the lowest to better show slow downs
|
||||
lastFps = Math.min(lastFps, xfps);
|
||||
};
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const ctx = canvasRef.current!.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.strokeStyle = '#ddd';
|
||||
|
||||
const now = Date.now();
|
||||
let missedFrames = 0;
|
||||
// check if we missed some measurements, in that case the CPU was fully choked!
|
||||
for (let i = 0; i < Math.floor((now - lastDraw) / sampleRate) - 1; i++) {
|
||||
fps.push(0);
|
||||
fps.shift();
|
||||
missedFrames++;
|
||||
}
|
||||
lastDraw = now;
|
||||
|
||||
// latest measurement
|
||||
fps.push(lastFps);
|
||||
fps.shift();
|
||||
|
||||
ctx.font = 'lighter 10px arial';
|
||||
ctx.strokeText(
|
||||
'' +
|
||||
(missedFrames
|
||||
? // if we were chocked, show FPS based on frames missed
|
||||
Math.floor((1000 / sampleRate) * missedFrames)
|
||||
: lastFps) +
|
||||
' fps',
|
||||
0,
|
||||
height - 4,
|
||||
);
|
||||
|
||||
ctx.moveTo(0, height);
|
||||
ctx.beginPath();
|
||||
ctx.lineWidth = 1;
|
||||
fps.forEach((num, idx) => {
|
||||
ctx.lineTo(idx, graphHeight - (Math.min(60, num) / 60) * graphHeight);
|
||||
});
|
||||
|
||||
ctx.strokeStyle = missedFrames ? '#ff0000' : '#ddd';
|
||||
|
||||
ctx.stroke();
|
||||
lastFps = 60;
|
||||
}, sampleRate);
|
||||
|
||||
fpsEmitter.on('fps', handler);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
fpsEmitter.off('fps', handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{width, height}}>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={width}
|
||||
height={height}
|
||||
title="Current framerate in FPS"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
256
desktop/flipper-ui-core/src/chrome/ListView.tsx
Normal file
256
desktop/flipper-ui-core/src/chrome/ListView.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
Text,
|
||||
FlexColumn,
|
||||
styled,
|
||||
FlexRow,
|
||||
Button,
|
||||
Spacer,
|
||||
Checkbox,
|
||||
Radio,
|
||||
View,
|
||||
Tooltip,
|
||||
Glyph,
|
||||
} from '../ui';
|
||||
import React, {Component} from 'react';
|
||||
import {theme} from 'flipper-plugin';
|
||||
|
||||
export type SelectionType = 'multiple' | 'single';
|
||||
|
||||
type SubType =
|
||||
| {
|
||||
selectedElements: Set<string>;
|
||||
type: 'multiple';
|
||||
}
|
||||
| {
|
||||
selectedElement: string;
|
||||
type: 'single';
|
||||
};
|
||||
|
||||
export type Element = {
|
||||
label: string;
|
||||
id: string;
|
||||
unselectable?: {toolTipMessage: string};
|
||||
};
|
||||
type Props = {
|
||||
onSubmit?: () => void;
|
||||
onChange: (elements: Array<string>) => void;
|
||||
onHide: () => any;
|
||||
elements: Array<Element>;
|
||||
title?: string;
|
||||
leftPadding?: number;
|
||||
} & SubType;
|
||||
|
||||
const Title = styled(Text)({
|
||||
margin: 6,
|
||||
});
|
||||
|
||||
type State = {
|
||||
selectedElements: Set<string>;
|
||||
};
|
||||
|
||||
const Container = styled(FlexColumn)({
|
||||
padding: '8 0',
|
||||
});
|
||||
|
||||
const Line = styled(View)({
|
||||
backgroundColor: theme.dividerColor,
|
||||
height: 1,
|
||||
width: 'auto',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
const RowComponentContainer = styled(FlexColumn)({
|
||||
overflow: 'scroll',
|
||||
height: 'auto',
|
||||
backgroundColor: theme.backgroundDefault,
|
||||
maxHeight: 500,
|
||||
});
|
||||
|
||||
const Padder = styled.div<{
|
||||
paddingLeft?: number;
|
||||
paddingRight?: number;
|
||||
paddingBottom?: number;
|
||||
paddingTop?: number;
|
||||
}>(({paddingLeft, paddingRight, paddingBottom, paddingTop}) => ({
|
||||
paddingLeft: paddingLeft || 0,
|
||||
paddingRight: paddingRight || 0,
|
||||
paddingBottom: paddingBottom || 0,
|
||||
paddingTop: paddingTop || 0,
|
||||
}));
|
||||
|
||||
type RowComponentProps = {
|
||||
id: string;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onChange: (name: string, selected: boolean) => void;
|
||||
disabled: boolean;
|
||||
toolTipMessage?: string;
|
||||
type: SelectionType;
|
||||
leftPadding?: number;
|
||||
};
|
||||
|
||||
class RowComponent extends Component<RowComponentProps> {
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
label,
|
||||
selected,
|
||||
onChange,
|
||||
disabled,
|
||||
toolTipMessage,
|
||||
type,
|
||||
leftPadding,
|
||||
} = this.props;
|
||||
return (
|
||||
<FlexColumn>
|
||||
<Tooltip
|
||||
title={disabled ? toolTipMessage : null}
|
||||
options={{position: 'toRight'}}>
|
||||
<Padder
|
||||
paddingRight={0}
|
||||
paddingTop={8}
|
||||
paddingBottom={8}
|
||||
paddingLeft={leftPadding || 0}>
|
||||
<FlexRow style={{alignItems: 'center'}}>
|
||||
<Text color={disabled ? theme.disabledColor : undefined}>
|
||||
{label}
|
||||
</Text>
|
||||
<Spacer />
|
||||
{disabled && (
|
||||
<Glyph
|
||||
name="caution-triangle"
|
||||
color={theme.dividerColor}
|
||||
size={12}
|
||||
variant="filled"
|
||||
style={{marginRight: 5}}
|
||||
/>
|
||||
)}
|
||||
{type === 'multiple' && (
|
||||
<Checkbox
|
||||
disabled={disabled}
|
||||
checked={selected}
|
||||
onChange={(selected) => {
|
||||
onChange(id, selected);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{type === 'single' && (
|
||||
<Radio
|
||||
disabled={disabled}
|
||||
checked={selected}
|
||||
onChange={(selected) => {
|
||||
onChange(id, selected);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</FlexRow>
|
||||
</Padder>
|
||||
<Line />
|
||||
</Tooltip>
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated use Ant Design instead
|
||||
*/
|
||||
export default class ListView extends Component<Props, State> {
|
||||
state: State = {selectedElements: new Set([])};
|
||||
static getDerivedStateFromProps(props: Props, _state: State) {
|
||||
if (props.type === 'multiple') {
|
||||
return {selectedElements: props.selectedElements};
|
||||
} else if (props.type === 'single') {
|
||||
return {selectedElements: new Set([props.selectedElement])};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
handleChange = (id: string, selected: boolean) => {
|
||||
let selectedElements: Set<string> = new Set([]);
|
||||
if (this.props.type === 'single') {
|
||||
if (!selected) {
|
||||
this.setState({selectedElements: selectedElements});
|
||||
this.props.onChange([...selectedElements]);
|
||||
} else {
|
||||
selectedElements.add(id);
|
||||
this.setState({selectedElements: selectedElements});
|
||||
this.props.onChange([...selectedElements]);
|
||||
}
|
||||
} else {
|
||||
if (selected) {
|
||||
selectedElements = new Set([...this.state.selectedElements, id]);
|
||||
this.props.onChange([...selectedElements]);
|
||||
} else {
|
||||
selectedElements = new Set([...this.state.selectedElements]);
|
||||
selectedElements.delete(id);
|
||||
this.props.onChange([...selectedElements]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const {onSubmit, type, leftPadding} = this.props;
|
||||
return (
|
||||
<Container>
|
||||
<FlexColumn>
|
||||
{this.props.title && <Title>{this.props.title}</Title>}
|
||||
<RowComponentContainer>
|
||||
{this.props.elements.map(({id, label, unselectable}) => {
|
||||
return (
|
||||
<RowComponent
|
||||
id={id}
|
||||
label={label}
|
||||
key={id}
|
||||
type={type}
|
||||
selected={this.state.selectedElements.has(id)}
|
||||
onChange={this.handleChange}
|
||||
disabled={unselectable != null}
|
||||
toolTipMessage={unselectable?.toolTipMessage}
|
||||
leftPadding={leftPadding}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</RowComponentContainer>
|
||||
</FlexColumn>
|
||||
{onSubmit && (
|
||||
<Padder paddingTop={8} paddingBottom={2}>
|
||||
<FlexRow>
|
||||
<Spacer />
|
||||
<Padder paddingRight={8}>
|
||||
<Button compact padded onClick={this.props.onHide}>
|
||||
Close
|
||||
</Button>
|
||||
</Padder>
|
||||
<Tooltip
|
||||
title={
|
||||
this.state.selectedElements.size <= 0
|
||||
? `Please select atleast one plugin`
|
||||
: null
|
||||
}
|
||||
options={{position: 'toRight'}}>
|
||||
<Button
|
||||
compact
|
||||
padded
|
||||
type="primary"
|
||||
onClick={onSubmit}
|
||||
disabled={this.state.selectedElements.size <= 0}>
|
||||
Submit
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</FlexRow>
|
||||
</Padder>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
94
desktop/flipper-ui-core/src/chrome/MetroButton.tsx
Normal file
94
desktop/flipper-ui-core/src/chrome/MetroButton.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* 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, {useCallback, useEffect, useState} from 'react';
|
||||
import {MetroReportableEvent} from 'flipper-common';
|
||||
import {useStore} from '../utils/useStore';
|
||||
import {Button as AntButton} from 'antd';
|
||||
import {MenuOutlined, ReloadOutlined} from '@ant-design/icons';
|
||||
import {theme} from 'flipper-plugin';
|
||||
import BaseDevice from '../devices/BaseDevice';
|
||||
|
||||
export default function MetroButton() {
|
||||
const device = useStore((state) =>
|
||||
state.connections.devices.find(
|
||||
(device) => device.os === 'Metro' && device.connected.get(),
|
||||
),
|
||||
) as BaseDevice | undefined;
|
||||
|
||||
const sendCommand = useCallback(
|
||||
(command: string) => {
|
||||
device?.sendMetroCommand(command);
|
||||
},
|
||||
[device],
|
||||
);
|
||||
const [progress, setProgress] = useState(1);
|
||||
const [_hasBuildError, setHasBuildError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!device) {
|
||||
return;
|
||||
}
|
||||
function metroEventListener(event: MetroReportableEvent) {
|
||||
if (event.type === 'bundle_build_started') {
|
||||
setHasBuildError(false);
|
||||
setProgress(0);
|
||||
} else if (event.type === 'bundle_build_failed') {
|
||||
setHasBuildError(true);
|
||||
setProgress(1);
|
||||
} else if (event.type === 'bundle_build_done') {
|
||||
setHasBuildError(false);
|
||||
setProgress(1);
|
||||
} else if (event.type === 'bundle_transform_progressed') {
|
||||
setProgress(event.transformedFileCount / event.totalFileCount);
|
||||
}
|
||||
}
|
||||
|
||||
const handle = device.addLogListener((l) => {
|
||||
if (l.tag !== 'client_log') {
|
||||
try {
|
||||
metroEventListener(JSON.parse(l.message));
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse metro message: ', l, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
device.removeLogListener(handle);
|
||||
};
|
||||
}, [device]);
|
||||
|
||||
if (!device) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<AntButton
|
||||
icon={<ReloadOutlined />}
|
||||
title="Reload React Native App"
|
||||
type="ghost"
|
||||
onClick={() => {
|
||||
sendCommand('reload');
|
||||
}}
|
||||
loading={progress < 1}
|
||||
style={{color: _hasBuildError ? theme.errorColor : undefined}}
|
||||
/>
|
||||
<AntButton
|
||||
icon={<MenuOutlined />}
|
||||
title="Open the React Native Dev Menu on the device"
|
||||
type="ghost"
|
||||
onClick={() => {
|
||||
sendCommand('devMenu');
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
desktop/flipper-ui-core/src/chrome/NetworkGraph.tsx
Normal file
69
desktop/flipper-ui-core/src/chrome/NetworkGraph.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* 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, {useEffect, useRef, useState} from 'react';
|
||||
import {onBytesReceived} from '../dispatcher/tracking';
|
||||
|
||||
const height = 16;
|
||||
const width = 36;
|
||||
|
||||
export default function NetworkGraph() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const lastTime = useRef(performance.now());
|
||||
const lastBytes = useRef(0);
|
||||
const pluginStats = useRef<Record<string, number>>({});
|
||||
const [hoverText, setHoverText] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
return onBytesReceived((plugin, bytes) => {
|
||||
lastBytes.current += bytes;
|
||||
if (!pluginStats.current[plugin]) {
|
||||
pluginStats.current[plugin] = bytes;
|
||||
} else {
|
||||
pluginStats.current[plugin] += bytes;
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const deltaTime = performance.now() - lastTime.current;
|
||||
lastTime.current = performance.now();
|
||||
const deltaBytes = lastBytes.current;
|
||||
lastBytes.current = 0;
|
||||
|
||||
// cause kiloBytesPerSecond === bytes per millisecond
|
||||
const kiloBytesPerSecond = Math.round(deltaBytes / deltaTime);
|
||||
|
||||
const ctx = canvasRef.current!.getContext('2d')!;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
ctx.strokeStyle = kiloBytesPerSecond >= 1000 ? '#f00' : '#ddd';
|
||||
ctx.font = 'lighter 10px arial';
|
||||
ctx.strokeText(`${kiloBytesPerSecond} kB/s`, 0, height - 4);
|
||||
|
||||
setHoverText(
|
||||
'Total data traffic per plugin:\n\n' +
|
||||
Object.entries(pluginStats.current)
|
||||
.sort(([_p, bytes], [_p2, bytes2]) => bytes2 - bytes)
|
||||
.map(([key, bytes]) => `${key}: ${Math.round(bytes / 1000)}kb`)
|
||||
.join('\n'),
|
||||
);
|
||||
}, 1000);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{width, height}}>
|
||||
<canvas ref={canvasRef} width={width} height={height} title={hoverText} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
desktop/flipper-ui-core/src/chrome/PlatformSelectWizard.tsx
Normal file
158
desktop/flipper-ui-core/src/chrome/PlatformSelectWizard.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* 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, {Component} from 'react';
|
||||
import {updateSettings, Action} from '../reducers/settings';
|
||||
import {connect} from 'react-redux';
|
||||
import {State as Store} from '../reducers';
|
||||
import {Settings} from '../reducers/settings';
|
||||
import {flush} from '../utils/persistor';
|
||||
import ToggledSection from './settings/ToggledSection';
|
||||
import {isEqual} from 'lodash';
|
||||
import {reportUsage} from 'flipper-common';
|
||||
import {Modal, Button} from 'antd';
|
||||
import {Layout, withTrackingScope, _NuxManagerContext} from 'flipper-plugin';
|
||||
import {getRenderHostInstance} from '../RenderHost';
|
||||
|
||||
const WIZARD_FINISHED_LOCAL_STORAGE_KEY = 'platformSelectWizardFinished';
|
||||
|
||||
type OwnProps = {
|
||||
onHide: () => void;
|
||||
platform: NodeJS.Platform;
|
||||
};
|
||||
|
||||
type StateFromProps = {
|
||||
settings: Settings;
|
||||
};
|
||||
|
||||
type DispatchFromProps = {
|
||||
updateSettings: (settings: Settings) => Action;
|
||||
};
|
||||
|
||||
type State = {
|
||||
updatedSettings: Settings;
|
||||
forcedRestartSettings: Partial<Settings>;
|
||||
};
|
||||
|
||||
type Props = OwnProps & StateFromProps & DispatchFromProps;
|
||||
class PlatformSelectWizard extends Component<Props, State> {
|
||||
state: State = {
|
||||
updatedSettings: {...this.props.settings},
|
||||
forcedRestartSettings: {},
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
reportUsage('platformwizard:opened');
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
reportUsage('platformwizard:closed');
|
||||
}
|
||||
|
||||
applyChanges = async (settingsPristine: boolean) => {
|
||||
this.props.updateSettings(this.state.updatedSettings);
|
||||
|
||||
markWizardAsCompleted();
|
||||
|
||||
this.props.onHide();
|
||||
|
||||
return flush().then(() => {
|
||||
if (!settingsPristine) {
|
||||
reportUsage('platformwizard:action:changed');
|
||||
getRenderHostInstance().restartFlipper();
|
||||
} else {
|
||||
reportUsage('platformwizard:action:noop');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const {enableAndroid, enableIOS} = this.state.updatedSettings;
|
||||
|
||||
const settingsPristine = isEqual(
|
||||
this.props.settings,
|
||||
this.state.updatedSettings,
|
||||
);
|
||||
|
||||
const contents = (
|
||||
<Layout.Container gap>
|
||||
<Layout.Container style={{width: '100%', paddingBottom: 15}}>
|
||||
<>
|
||||
Please select the targets you intend to debug, so that we can
|
||||
optimise the configuration for the selected targets.
|
||||
</>
|
||||
</Layout.Container>
|
||||
<ToggledSection
|
||||
label="Android Developer"
|
||||
toggled={enableAndroid}
|
||||
onChange={(v) => {
|
||||
this.setState({
|
||||
updatedSettings: {
|
||||
...this.state.updatedSettings,
|
||||
enableAndroid: v,
|
||||
},
|
||||
});
|
||||
}}></ToggledSection>
|
||||
<ToggledSection
|
||||
label="iOS Developer"
|
||||
toggled={enableIOS && this.props.platform === 'darwin'}
|
||||
onChange={(v) => {
|
||||
this.setState({
|
||||
updatedSettings: {...this.state.updatedSettings, enableIOS: v},
|
||||
});
|
||||
}}></ToggledSection>
|
||||
</Layout.Container>
|
||||
);
|
||||
|
||||
const footerText = settingsPristine ? 'Looks fine' : 'Apply and Restart';
|
||||
const footer = (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => this.applyChanges(settingsPristine)}>
|
||||
{footerText}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
onCancel={() => {
|
||||
this.props.onHide();
|
||||
markWizardAsCompleted();
|
||||
}}
|
||||
width={570}
|
||||
title="Select Platform Configuration"
|
||||
footer={footer}>
|
||||
{contents}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
|
||||
({settingsState}) => ({
|
||||
settings: settingsState,
|
||||
}),
|
||||
{updateSettings},
|
||||
)(withTrackingScope(PlatformSelectWizard));
|
||||
|
||||
export function hasPlatformWizardBeenDone(
|
||||
localStorage: Storage | undefined,
|
||||
): boolean {
|
||||
return (
|
||||
!localStorage ||
|
||||
localStorage.getItem(WIZARD_FINISHED_LOCAL_STORAGE_KEY) !== 'true'
|
||||
);
|
||||
}
|
||||
|
||||
function markWizardAsCompleted() {
|
||||
window.localStorage.setItem(WIZARD_FINISHED_LOCAL_STORAGE_KEY, 'true');
|
||||
}
|
||||
126
desktop/flipper-ui-core/src/chrome/PluginActions.tsx
Normal file
126
desktop/flipper-ui-core/src/chrome/PluginActions.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
DownloadOutlined,
|
||||
LoadingOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {Alert, Button} from 'antd';
|
||||
import {
|
||||
BundledPluginDetails,
|
||||
DownloadablePluginDetails,
|
||||
} from 'flipper-plugin-lib';
|
||||
import React, {useMemo} from 'react';
|
||||
import {useCallback} from 'react';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
import {PluginDefinition} from '../plugin';
|
||||
import {startPluginDownload} from '../reducers/pluginDownloads';
|
||||
import {loadPlugin, switchPlugin} from '../reducers/pluginManager';
|
||||
import {
|
||||
getActiveClient,
|
||||
getPluginDownloadStatusMap,
|
||||
} from '../selectors/connections';
|
||||
import {Layout} from '../ui';
|
||||
import {ActivePluginListItem} from '../utils/pluginUtils';
|
||||
|
||||
export function PluginActions({
|
||||
activePlugin,
|
||||
type,
|
||||
}: {
|
||||
activePlugin: ActivePluginListItem;
|
||||
type: 'link' | 'primary';
|
||||
}) {
|
||||
switch (activePlugin.status) {
|
||||
case 'disabled': {
|
||||
return <EnableButton plugin={activePlugin.definition} type={type} />;
|
||||
}
|
||||
case 'uninstalled': {
|
||||
return <InstallButton plugin={activePlugin.details} type={type} />;
|
||||
}
|
||||
case 'unavailable': {
|
||||
return type === 'primary' ? (
|
||||
<UnavailabilityAlert reason={activePlugin.reason} />
|
||||
) : null;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function EnableButton({
|
||||
plugin,
|
||||
type,
|
||||
}: {
|
||||
plugin: PluginDefinition;
|
||||
type: 'link' | 'primary';
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const client = useSelector(getActiveClient);
|
||||
const enableOrDisablePlugin = useCallback(() => {
|
||||
dispatch(switchPlugin({plugin, selectedApp: client?.query?.app}));
|
||||
}, [dispatch, plugin, client]);
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={enableOrDisablePlugin}
|
||||
style={{flexGrow: type == 'primary' ? 1 : 0}}>
|
||||
Enable Plugin
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function UnavailabilityAlert({reason}: {reason: string}) {
|
||||
return (
|
||||
<Layout.Container center>
|
||||
<Alert message={reason} type="warning" />
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
|
||||
function InstallButton({
|
||||
plugin,
|
||||
type = 'primary',
|
||||
}: {
|
||||
plugin: DownloadablePluginDetails | BundledPluginDetails;
|
||||
type: 'link' | 'primary';
|
||||
}) {
|
||||
const dispatch = useDispatch();
|
||||
const installPlugin = useCallback(() => {
|
||||
if (plugin.isBundled) {
|
||||
dispatch(loadPlugin({plugin, enable: true, notifyIfFailed: true}));
|
||||
} else {
|
||||
dispatch(startPluginDownload({plugin, startedByUser: true}));
|
||||
}
|
||||
}, [plugin, dispatch]);
|
||||
const downloads = useSelector(getPluginDownloadStatusMap);
|
||||
const downloadStatus = useMemo(
|
||||
() => downloads.get(plugin.id),
|
||||
[downloads, plugin],
|
||||
);
|
||||
return (
|
||||
<Button
|
||||
type={type}
|
||||
disabled={!!downloadStatus}
|
||||
icon={
|
||||
downloadStatus ? (
|
||||
<LoadingOutlined size={16} />
|
||||
) : (
|
||||
<DownloadOutlined size={16} />
|
||||
)
|
||||
}
|
||||
onClick={installPlugin}
|
||||
style={{
|
||||
flexGrow: type === 'primary' ? 1 : 0,
|
||||
}}>
|
||||
Install Plugin
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
154
desktop/flipper-ui-core/src/chrome/PluginActionsMenu.tsx
Normal file
154
desktop/flipper-ui-core/src/chrome/PluginActionsMenu.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import Icon, {MacCommandOutlined} from '@ant-design/icons';
|
||||
import {css} from '@emotion/css';
|
||||
import {Button, Menu, MenuItemProps, Row, Tooltip} from 'antd';
|
||||
import {
|
||||
NormalizedMenuEntry,
|
||||
NUX,
|
||||
TrackingScope,
|
||||
useTrackedCallback,
|
||||
} from 'flipper-plugin';
|
||||
import React, {useEffect} from 'react';
|
||||
import {getRenderHostInstance} from '../RenderHost';
|
||||
import {getActivePlugin} from '../selectors/connections';
|
||||
import {useStore} from '../utils/useStore';
|
||||
|
||||
function MagicIcon() {
|
||||
return (
|
||||
// https://www.svgrepo.com/svg/59702/magic
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 464.731 464.731"
|
||||
fill="currentColor">
|
||||
<title>Magic</title>
|
||||
<path
|
||||
d="M463.056,441.971l-45.894-43.145l29.759-55.521c0.8-1.508,0.379-3.398-1.029-4.395
|
||||
c-1.388-1.011-3.305-0.832-4.487,0.424l-43.146,45.895l-55.533-29.746c-1.515-0.803-3.399-0.377-4.395,1.027
|
||||
c-1.017,1.392-0.815,3.309,0.438,4.488l45.911,43.162l-29.747,55.518c-0.816,1.525-0.378,3.401,1.01,4.412
|
||||
c1.41,0.996,3.326,0.816,4.502-0.438l43.149-45.912l55.507,29.746c1.506,0.802,3.393,0.378,4.393-1.027
|
||||
C464.506,445.072,464.308,443.136,463.056,441.971z"
|
||||
/>
|
||||
<path
|
||||
d="M369.086,94.641l-20.273,37.826c-1.04,1.918-0.479,4.307,1.285,5.588c1.783,1.271,4.215,1.029,5.71-0.559
|
||||
l29.417-31.269l37.78,20.26c1.921,1.024,4.323,0.484,5.589-1.285c1.271-1.783,1.048-4.215-0.555-5.709l-31.245-29.385
|
||||
l20.274-37.814c1.028-1.918,0.466-4.307-1.297-5.59c-1.766-1.268-4.216-1.025-5.713,0.558l-29.381,31.257l-37.814-20.273
|
||||
c-1.936-1.026-4.325-0.467-5.589,1.301c-1.273,1.766-1.042,4.214,0.544,5.711L369.086,94.641z"
|
||||
/>
|
||||
<path
|
||||
d="M123.956,360.06l-44.659,6.239l-17.611-41.484c-0.906-2.113-3.217-3.232-5.423-2.631
|
||||
c-2.226,0.623-3.626,2.78-3.313,5.051l6.239,44.639L17.69,389.489c-2.1,0.908-3.23,3.217-2.614,5.424
|
||||
c0.609,2.219,2.767,3.629,5.032,3.31l44.657-6.241l17.611,41.5c0.896,2.118,3.218,3.236,5.425,2.629
|
||||
c2.206-0.617,3.626-2.765,3.312-5.043l-6.238-44.658l41.5-17.617c2.099-0.904,3.234-3.217,2.612-5.423
|
||||
C128.383,361.147,126.221,359.745,123.956,360.06z"
|
||||
/>
|
||||
<path
|
||||
d="M4.908,45.161l34.646,9.537l-0.23,35.832c-0.012,2.01,1.449,3.704,3.447,3.99
|
||||
c1.976,0.271,3.851-0.969,4.377-2.901l9.521-34.565l35.923,0.225c2.01,0.016,3.702-1.447,3.992-3.441
|
||||
c0.271-1.982-0.97-3.853-2.905-4.383l-34.627-9.547l0.213-35.881c0.018-2.01-1.466-3.701-3.441-3.988
|
||||
c-1.983-0.273-3.856,0.965-4.383,2.901l-9.533,34.608L5.996,37.324c-1.991,0-3.701,1.463-3.974,3.441
|
||||
C1.751,42.747,2.992,44.633,4.908,45.161z"
|
||||
/>
|
||||
<path
|
||||
d="M278.019,234.519l139.775-18.477c1.586-0.21,2.762-1.555,2.762-3.143c0-1.587-1.176-2.928-2.762-3.142
|
||||
L278.019,191.28l20.476-57.755c0.857-2.446,0.235-5.183-1.603-7.009c-1.828-1.844-4.567-2.445-7.01-1.586l-57.697,20.484
|
||||
L213.708,5.688c-0.194-1.588-1.554-2.764-3.14-2.764c-1.584,0-2.935,1.176-3.146,2.764l-18.457,139.744l-57.772-20.502
|
||||
c-2.448-0.875-5.181-0.258-7.014,1.586c-1.84,1.826-2.46,4.563-1.586,7.009l20.489,57.772l-139.73,18.46
|
||||
c-1.584,0.214-2.762,1.555-2.762,3.142c0,1.588,1.178,2.933,2.762,3.143l139.73,18.461l-20.489,57.742
|
||||
c-0.874,2.447-0.254,5.182,1.586,7.01c1.833,1.842,4.565,2.462,7.014,1.582l57.772-20.467l18.457,139.743
|
||||
c0.212,1.583,1.563,2.764,3.146,2.764c1.586,0,2.945-1.181,3.14-2.764l18.477-139.743l57.727,20.486
|
||||
c2.441,0.876,5.181,0.256,7.009-1.589c1.845-1.825,2.461-4.562,1.584-7.007L278.019,234.519z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
const menu = css`
|
||||
border: none;
|
||||
`;
|
||||
const submenu = css`
|
||||
.ant-menu-submenu-title {
|
||||
width: 32px;
|
||||
height: 32px !important;
|
||||
line-height: 32px !important;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.ant-menu-submenu-arrow {
|
||||
display: none;
|
||||
}
|
||||
`;
|
||||
|
||||
function PluginActionMenuItem({
|
||||
label,
|
||||
action,
|
||||
handler,
|
||||
accelerator,
|
||||
// Some props like `eventKey` are auto-generated by ant-design
|
||||
// We need to pass them through to MenuItem
|
||||
...antdProps
|
||||
}: NormalizedMenuEntry & MenuItemProps) {
|
||||
const trackedHandler = useTrackedCallback(action, handler, [action, handler]);
|
||||
|
||||
useEffect(() => {
|
||||
if (accelerator) {
|
||||
const unregister = getRenderHostInstance().registerShortcut(
|
||||
accelerator,
|
||||
trackedHandler,
|
||||
);
|
||||
return unregister;
|
||||
}
|
||||
}, [trackedHandler, accelerator]);
|
||||
|
||||
return (
|
||||
<Menu.Item onClick={trackedHandler} {...antdProps}>
|
||||
<Row justify="space-between" align="middle">
|
||||
{label}
|
||||
{accelerator ? (
|
||||
<Tooltip title={accelerator} placement="right">
|
||||
<MacCommandOutlined />
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</Row>
|
||||
</Menu.Item>
|
||||
);
|
||||
}
|
||||
export function PluginActionsMenu() {
|
||||
const menuEntries = useStore((state) => state.connections.pluginMenuEntries);
|
||||
const activePlugin = useStore(getActivePlugin);
|
||||
|
||||
if (!menuEntries.length || !activePlugin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TrackingScope scope={`PluginActionsButton:${activePlugin.details.id}`}>
|
||||
<NUX title="Use custom plugin actions and shortcuts" placement="right">
|
||||
<Menu mode="vertical" className={menu} selectable={false}>
|
||||
<Menu.SubMenu
|
||||
popupOffset={[15, 0]}
|
||||
key="pluginActions"
|
||||
title={
|
||||
<Button
|
||||
icon={<Icon component={MagicIcon} />}
|
||||
title="Plugin actions"
|
||||
type="ghost"
|
||||
/>
|
||||
}
|
||||
className={submenu}>
|
||||
{menuEntries.map((entry) => (
|
||||
<PluginActionMenuItem key={entry.action} {...entry} />
|
||||
))}
|
||||
</Menu.SubMenu>
|
||||
</Menu>
|
||||
</NUX>
|
||||
</TrackingScope>
|
||||
);
|
||||
}
|
||||
355
desktop/flipper-ui-core/src/chrome/RatingButton.tsx
Normal file
355
desktop/flipper-ui-core/src/chrome/RatingButton.tsx
Normal file
@@ -0,0 +1,355 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* 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, {
|
||||
Component,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
FlexColumn,
|
||||
FlexRow,
|
||||
Button,
|
||||
Checkbox,
|
||||
styled,
|
||||
Input,
|
||||
Link,
|
||||
} from '../ui';
|
||||
import {LeftRailButton} from '../sandy-chrome/LeftRail';
|
||||
import GK from '../fb-stubs/GK';
|
||||
import * as UserFeedback from '../fb-stubs/UserFeedback';
|
||||
import {FeedbackPrompt} from '../fb-stubs/UserFeedback';
|
||||
import {StarOutlined} from '@ant-design/icons';
|
||||
import {Popover, Rate} from 'antd';
|
||||
import {useStore} from '../utils/useStore';
|
||||
import {isLoggedIn} from '../fb-stubs/user';
|
||||
import {useValue} from 'flipper-plugin';
|
||||
import {reportPlatformFailures} from 'flipper-common';
|
||||
|
||||
type NextAction = 'select-rating' | 'leave-comment' | 'finished';
|
||||
|
||||
class PredefinedComment extends Component<{
|
||||
comment: string;
|
||||
selected: boolean;
|
||||
onClick: (_: unknown) => unknown;
|
||||
}> {
|
||||
static Container = styled.div<{selected: boolean}>((props) => {
|
||||
return {
|
||||
border: '1px solid #f2f3f5',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 24,
|
||||
backgroundColor: props.selected ? '#ecf3ff' : '#f2f3f5',
|
||||
marginBottom: 4,
|
||||
marginRight: 4,
|
||||
padding: '4px 8px',
|
||||
color: props.selected ? 'rgb(56, 88, 152)' : undefined,
|
||||
borderColor: props.selected ? '#3578e5' : undefined,
|
||||
':hover': {
|
||||
borderColor: '#3578e5',
|
||||
},
|
||||
};
|
||||
});
|
||||
render() {
|
||||
return (
|
||||
<PredefinedComment.Container
|
||||
onClick={this.props.onClick}
|
||||
selected={this.props.selected}>
|
||||
{this.props.comment}
|
||||
</PredefinedComment.Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const Row = styled(FlexRow)({
|
||||
marginTop: 5,
|
||||
marginBottom: 5,
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
color: '#9a9a9a',
|
||||
flexWrap: 'wrap',
|
||||
});
|
||||
|
||||
const DismissRow = styled(Row)({
|
||||
marginBottom: 0,
|
||||
marginTop: 10,
|
||||
});
|
||||
|
||||
const DismissButton = styled.span({
|
||||
'&:hover': {
|
||||
textDecoration: 'underline',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
});
|
||||
|
||||
const Spacer = styled(FlexColumn)({
|
||||
flexGrow: 1,
|
||||
});
|
||||
|
||||
function dismissRow(dismiss: () => void) {
|
||||
return (
|
||||
<DismissRow key="dismiss">
|
||||
<Spacer />
|
||||
<DismissButton onClick={dismiss}>Dismiss</DismissButton>
|
||||
<Spacer />
|
||||
</DismissRow>
|
||||
);
|
||||
}
|
||||
|
||||
type FeedbackComponentState = {
|
||||
rating: number | null;
|
||||
hoveredRating: number;
|
||||
allowUserInfoSharing: boolean;
|
||||
nextAction: NextAction;
|
||||
predefinedComments: {[key: string]: boolean};
|
||||
comment: string;
|
||||
};
|
||||
|
||||
class FeedbackComponent extends Component<
|
||||
{
|
||||
submitRating: (rating: number) => void;
|
||||
submitComment: (
|
||||
rating: number,
|
||||
comment: string,
|
||||
selectedPredefinedComments: Array<string>,
|
||||
allowUserInfoSharing: boolean,
|
||||
) => void;
|
||||
close: () => void;
|
||||
dismiss: () => void;
|
||||
promptData: FeedbackPrompt;
|
||||
},
|
||||
FeedbackComponentState
|
||||
> {
|
||||
state: FeedbackComponentState = {
|
||||
rating: null,
|
||||
hoveredRating: 0,
|
||||
allowUserInfoSharing: true,
|
||||
nextAction: 'select-rating' as NextAction,
|
||||
predefinedComments: this.props.promptData.predefinedComments.reduce(
|
||||
(acc, cv) => ({...acc, [cv]: false}),
|
||||
{},
|
||||
),
|
||||
comment: '',
|
||||
};
|
||||
onSubmitRating(newRating: number) {
|
||||
const nextAction = newRating <= 2 ? 'leave-comment' : 'finished';
|
||||
this.setState({rating: newRating, nextAction: nextAction});
|
||||
this.props.submitRating(newRating);
|
||||
if (nextAction === 'finished') {
|
||||
setTimeout(this.props.close, 5000);
|
||||
}
|
||||
}
|
||||
onCommentSubmitted(comment: string) {
|
||||
this.setState({nextAction: 'finished'});
|
||||
const selectedPredefinedComments: Array<string> = Object.entries(
|
||||
this.state.predefinedComments,
|
||||
)
|
||||
.map((x) => ({comment: x[0], enabled: x[1]}))
|
||||
.filter((x) => x.enabled)
|
||||
.map((x) => x.comment);
|
||||
const currentRating = this.state.rating;
|
||||
if (currentRating) {
|
||||
this.props.submitComment(
|
||||
currentRating,
|
||||
comment,
|
||||
selectedPredefinedComments,
|
||||
this.state.allowUserInfoSharing,
|
||||
);
|
||||
} else {
|
||||
console.error('Illegal state: Submitting comment with no rating set.');
|
||||
}
|
||||
setTimeout(this.props.close, 1000);
|
||||
}
|
||||
onAllowUserSharingChanged(allowed: boolean) {
|
||||
this.setState({allowUserInfoSharing: allowed});
|
||||
}
|
||||
render() {
|
||||
let body: Array<ReactElement>;
|
||||
switch (this.state.nextAction) {
|
||||
case 'select-rating':
|
||||
body = [
|
||||
<Row key="bodyText">{this.props.promptData.bodyText}</Row>,
|
||||
<Row key="stars" style={{margin: 'auto'}}>
|
||||
<Rate onChange={(newRating) => this.onSubmitRating(newRating)} />
|
||||
</Row>,
|
||||
dismissRow(this.props.dismiss),
|
||||
];
|
||||
break;
|
||||
case 'leave-comment':
|
||||
const predefinedComments = Object.entries(
|
||||
this.state.predefinedComments,
|
||||
).map((c: [string, unknown], idx: number) => (
|
||||
<PredefinedComment
|
||||
key={idx}
|
||||
comment={c[0]}
|
||||
selected={Boolean(c[1])}
|
||||
onClick={() =>
|
||||
this.setState({
|
||||
predefinedComments: {
|
||||
...this.state.predefinedComments,
|
||||
[c[0]]: !c[1],
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
));
|
||||
body = [
|
||||
<Row key="predefinedComments">{predefinedComments}</Row>,
|
||||
<Row key="inputRow">
|
||||
<Input
|
||||
style={{height: 30, width: '100%'}}
|
||||
placeholder={this.props.promptData.commentPlaceholder}
|
||||
value={this.state.comment}
|
||||
onChange={(e) => this.setState({comment: e.target.value})}
|
||||
onKeyDown={(e) =>
|
||||
e.key == 'Enter' && this.onCommentSubmitted(this.state.comment)
|
||||
}
|
||||
autoFocus
|
||||
/>
|
||||
</Row>,
|
||||
<Row key="contactCheckbox">
|
||||
<Checkbox
|
||||
checked={this.state.allowUserInfoSharing}
|
||||
onChange={this.onAllowUserSharingChanged.bind(this)}
|
||||
/>
|
||||
{'Tool owner can contact me '}
|
||||
</Row>,
|
||||
<Row key="submit">
|
||||
<Button onClick={() => this.onCommentSubmitted(this.state.comment)}>
|
||||
Submit
|
||||
</Button>
|
||||
</Row>,
|
||||
dismissRow(this.props.dismiss),
|
||||
];
|
||||
break;
|
||||
case 'finished':
|
||||
body = [
|
||||
<Row key="thanks">
|
||||
Thanks for the feedback! You can now help
|
||||
<Link href="https://www.internalfb.com/intern/papercuts/?application=flipper">
|
||||
prioritize bugs and features for Flipper in Papercuts
|
||||
</Link>
|
||||
</Row>,
|
||||
dismissRow(this.props.dismiss),
|
||||
];
|
||||
break;
|
||||
default: {
|
||||
console.error('Illegal state: nextAction: ' + this.state.nextAction);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<FlexColumn
|
||||
style={{
|
||||
width: 400,
|
||||
paddingLeft: 20,
|
||||
paddingRight: 20,
|
||||
paddingTop: 10,
|
||||
paddingBottom: 10,
|
||||
}}>
|
||||
<Row key="heading" style={{color: 'black', fontSize: 20}}>
|
||||
{this.state.nextAction === 'finished'
|
||||
? this.props.promptData.postSubmitHeading
|
||||
: this.props.promptData.preSubmitHeading}
|
||||
</Row>
|
||||
{body}
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function SandyRatingButton() {
|
||||
const [promptData, setPromptData] =
|
||||
useState<UserFeedback.FeedbackPrompt | null>(null);
|
||||
const [isShown, setIsShown] = useState(false);
|
||||
const [hasTriggered, setHasTriggered] = useState(false);
|
||||
const sessionId = useStore((store) => store.application.sessionId);
|
||||
const loggedIn = useValue(isLoggedIn());
|
||||
|
||||
const triggerPopover = useCallback(() => {
|
||||
if (!hasTriggered) {
|
||||
setIsShown(true);
|
||||
setHasTriggered(true);
|
||||
}
|
||||
}, [hasTriggered]);
|
||||
|
||||
useEffect(() => {
|
||||
if (GK.get('flipper_enable_star_ratiings') && !hasTriggered && loggedIn) {
|
||||
reportPlatformFailures(
|
||||
UserFeedback.getPrompt().then((prompt) => {
|
||||
setPromptData(prompt);
|
||||
setTimeout(triggerPopover, 30000);
|
||||
}),
|
||||
'RatingButton:getPrompt',
|
||||
).catch((e) => {
|
||||
console.warn('Failed to load ratings prompt:', e);
|
||||
});
|
||||
}
|
||||
}, [triggerPopover, hasTriggered, loggedIn]);
|
||||
|
||||
const onClick = () => {
|
||||
const willBeShown = !isShown;
|
||||
setIsShown(willBeShown);
|
||||
setHasTriggered(true);
|
||||
if (!willBeShown) {
|
||||
UserFeedback.dismiss(sessionId);
|
||||
}
|
||||
};
|
||||
|
||||
const submitRating = (rating: number) => {
|
||||
UserFeedback.submitRating(rating, sessionId);
|
||||
};
|
||||
|
||||
const submitComment = (
|
||||
rating: number,
|
||||
comment: string,
|
||||
selectedPredefinedComments: Array<string>,
|
||||
allowUserInfoSharing: boolean,
|
||||
) => {
|
||||
UserFeedback.submitComment(
|
||||
rating,
|
||||
comment,
|
||||
selectedPredefinedComments,
|
||||
allowUserInfoSharing,
|
||||
sessionId,
|
||||
);
|
||||
};
|
||||
|
||||
if (!promptData) {
|
||||
return null;
|
||||
}
|
||||
if (!promptData.shouldPopup || (hasTriggered && !isShown)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Popover
|
||||
visible={isShown}
|
||||
content={
|
||||
<FeedbackComponent
|
||||
submitRating={submitRating}
|
||||
submitComment={submitComment}
|
||||
close={() => {
|
||||
setIsShown(false);
|
||||
}}
|
||||
dismiss={onClick}
|
||||
promptData={promptData}
|
||||
/>
|
||||
}
|
||||
placement="right"
|
||||
trigger="click">
|
||||
<LeftRailButton
|
||||
icon={<StarOutlined />}
|
||||
title="Rate Flipper"
|
||||
onClick={onClick}
|
||||
small
|
||||
/>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
126
desktop/flipper-ui-core/src/chrome/ScreenCaptureButtons.tsx
Normal file
126
desktop/flipper-ui-core/src/chrome/ScreenCaptureButtons.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Button as AntButton, message} from 'antd';
|
||||
import React, {useState, useEffect, useCallback} from 'react';
|
||||
import path from 'path';
|
||||
import fs from 'fs-extra';
|
||||
import open from 'open';
|
||||
import {capture, getCaptureLocation, getFileName} from '../utils/screenshot';
|
||||
import {CameraOutlined, VideoCameraOutlined} from '@ant-design/icons';
|
||||
import {useStore} from '../utils/useStore';
|
||||
|
||||
async function openFile(path: string | null) {
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
let fileStat;
|
||||
try {
|
||||
fileStat = await fs.stat(path);
|
||||
} catch (err) {
|
||||
message.error(`Couldn't open captured file: ${path}: ${err}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Rather randomly chosen. Some FSs still reserve 8 bytes for empty files.
|
||||
// If this doesn't reliably catch "corrupt" files, you might want to increase this.
|
||||
if (fileStat.size <= 8) {
|
||||
message.error(
|
||||
'Screencap file retrieved from device appears to be corrupt. Your device may not support screen recording. Sometimes restarting your device can help.',
|
||||
0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await open(path);
|
||||
} catch (e) {
|
||||
console.warn(`Opening ${path} failed with error ${e}.`);
|
||||
}
|
||||
}
|
||||
|
||||
export default function ScreenCaptureButtons() {
|
||||
const selectedDevice = useStore((state) => state.connections.selectedDevice);
|
||||
const [isTakingScreenshot, setIsTakingScreenshot] = useState(false);
|
||||
const [isRecordingAvailable, setIsRecordingAvailable] = useState(false);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
selectedDevice?.screenCaptureAvailable().then((result) => {
|
||||
if (!canceled) {
|
||||
setIsRecordingAvailable(result);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [selectedDevice]);
|
||||
|
||||
const handleScreenshot = useCallback(() => {
|
||||
setIsTakingScreenshot(true);
|
||||
return capture(selectedDevice!)
|
||||
.then(openFile)
|
||||
.catch((e) => {
|
||||
console.error('Taking screenshot failed:', e);
|
||||
message.error('Taking screenshot failed:' + e);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsTakingScreenshot(false);
|
||||
});
|
||||
}, [selectedDevice]);
|
||||
|
||||
const handleRecording = useCallback(() => {
|
||||
if (!selectedDevice) {
|
||||
return;
|
||||
}
|
||||
if (!isRecording) {
|
||||
setIsRecording(true);
|
||||
const videoPath = path.join(getCaptureLocation(), getFileName('mp4'));
|
||||
return selectedDevice.startScreenCapture(videoPath).catch((e) => {
|
||||
console.error('Failed to start recording', e);
|
||||
message.error('Failed to start recording' + e);
|
||||
setIsRecording(false);
|
||||
});
|
||||
} else {
|
||||
return selectedDevice
|
||||
.stopScreenCapture()
|
||||
.then(openFile)
|
||||
.catch((e) => {
|
||||
console.error('Failed to start recording', e);
|
||||
message.error('Failed to start recording' + e);
|
||||
})
|
||||
.finally(() => {
|
||||
setIsRecording(false);
|
||||
});
|
||||
}
|
||||
}, [selectedDevice, isRecording]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AntButton
|
||||
icon={<CameraOutlined />}
|
||||
title="Take Screenshot"
|
||||
type="ghost"
|
||||
onClick={handleScreenshot}
|
||||
disabled={!selectedDevice}
|
||||
loading={isTakingScreenshot}
|
||||
/>
|
||||
<AntButton
|
||||
icon={<VideoCameraOutlined />}
|
||||
title="Make Screen Recording"
|
||||
type={isRecording ? 'primary' : 'ghost'}
|
||||
onClick={handleRecording}
|
||||
disabled={!selectedDevice || !isRecordingAvailable}
|
||||
danger={isRecording}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
389
desktop/flipper-ui-core/src/chrome/SettingsSheet.tsx
Normal file
389
desktop/flipper-ui-core/src/chrome/SettingsSheet.tsx
Normal file
@@ -0,0 +1,389 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* 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, {Component, useContext} from 'react';
|
||||
import {Radio} from 'antd';
|
||||
import {updateSettings, Action} from '../reducers/settings';
|
||||
import {
|
||||
Action as LauncherAction,
|
||||
LauncherSettings,
|
||||
updateLauncherSettings,
|
||||
} from '../reducers/launcherSettings';
|
||||
import {connect} from 'react-redux';
|
||||
import {State as Store} from '../reducers';
|
||||
import {Settings, DEFAULT_ANDROID_SDK_PATH} from '../reducers/settings';
|
||||
import {flush} from '../utils/persistor';
|
||||
import ToggledSection from './settings/ToggledSection';
|
||||
import {FilePathConfigField, ConfigText} from './settings/configFields';
|
||||
import KeyboardShortcutInput from './settings/KeyboardShortcutInput';
|
||||
import {isEqual, isMatch, isEmpty} from 'lodash';
|
||||
import LauncherSettingsPanel from '../fb-stubs/LauncherSettingsPanel';
|
||||
import {reportUsage} from 'flipper-common';
|
||||
import {Modal, message, Button} from 'antd';
|
||||
import {Layout, withTrackingScope, _NuxManagerContext} from 'flipper-plugin';
|
||||
import {getRenderHostInstance} from '../RenderHost';
|
||||
|
||||
type OwnProps = {
|
||||
onHide: () => void;
|
||||
platform: NodeJS.Platform;
|
||||
noModal?: boolean; // used for testing
|
||||
};
|
||||
|
||||
type StateFromProps = {
|
||||
settings: Settings;
|
||||
launcherSettings: LauncherSettings;
|
||||
};
|
||||
|
||||
type DispatchFromProps = {
|
||||
updateSettings: (settings: Settings) => Action;
|
||||
updateLauncherSettings: (settings: LauncherSettings) => LauncherAction;
|
||||
};
|
||||
|
||||
type State = {
|
||||
updatedSettings: Settings;
|
||||
updatedLauncherSettings: LauncherSettings;
|
||||
forcedRestartSettings: Partial<Settings>;
|
||||
forcedRestartLauncherSettings: Partial<LauncherSettings>;
|
||||
};
|
||||
|
||||
type Props = OwnProps & StateFromProps & DispatchFromProps;
|
||||
class SettingsSheet extends Component<Props, State> {
|
||||
state: State = {
|
||||
updatedSettings: {...this.props.settings},
|
||||
updatedLauncherSettings: {...this.props.launcherSettings},
|
||||
forcedRestartSettings: {},
|
||||
forcedRestartLauncherSettings: {},
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
reportUsage('settings:opened');
|
||||
}
|
||||
|
||||
applyChanges = async () => {
|
||||
this.props.updateSettings(this.state.updatedSettings);
|
||||
this.props.updateLauncherSettings(this.state.updatedLauncherSettings);
|
||||
this.props.onHide();
|
||||
return flush().then(() => {
|
||||
getRenderHostInstance().restartFlipper(true);
|
||||
});
|
||||
};
|
||||
|
||||
applyChangesWithoutRestart = async () => {
|
||||
this.props.updateSettings(this.state.updatedSettings);
|
||||
this.props.updateLauncherSettings(this.state.updatedLauncherSettings);
|
||||
await flush();
|
||||
this.props.onHide();
|
||||
};
|
||||
|
||||
renderSandyContainer(
|
||||
contents: React.ReactElement,
|
||||
footer: React.ReactElement,
|
||||
) {
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
onCancel={this.props.onHide}
|
||||
width={570}
|
||||
title="Settings"
|
||||
footer={footer}
|
||||
bodyStyle={{
|
||||
overflow: 'scroll',
|
||||
maxHeight: 'calc(100vh - 250px)',
|
||||
}}>
|
||||
{contents}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
enableAndroid,
|
||||
androidHome,
|
||||
enableIOS,
|
||||
enablePhysicalIOS,
|
||||
enablePrefetching,
|
||||
idbPath,
|
||||
reactNative,
|
||||
darkMode,
|
||||
suppressPluginErrors,
|
||||
} = this.state.updatedSettings;
|
||||
|
||||
const settingsPristine =
|
||||
isEqual(this.props.settings, this.state.updatedSettings) &&
|
||||
isEqual(this.props.launcherSettings, this.state.updatedLauncherSettings);
|
||||
|
||||
const forcedRestart =
|
||||
(!isEmpty(this.state.forcedRestartSettings) &&
|
||||
!isMatch(this.props.settings, this.state.forcedRestartSettings)) ||
|
||||
(!isEmpty(this.state.forcedRestartLauncherSettings) &&
|
||||
!isMatch(
|
||||
this.props.launcherSettings,
|
||||
this.state.forcedRestartLauncherSettings,
|
||||
));
|
||||
|
||||
const contents = (
|
||||
<Layout.Container gap>
|
||||
<ToggledSection
|
||||
label="Android Developer"
|
||||
toggled={enableAndroid}
|
||||
onChange={(v) => {
|
||||
this.setState({
|
||||
updatedSettings: {
|
||||
...this.state.updatedSettings,
|
||||
enableAndroid: v,
|
||||
},
|
||||
});
|
||||
}}>
|
||||
<FilePathConfigField
|
||||
label="Android SDK location"
|
||||
resetValue={DEFAULT_ANDROID_SDK_PATH}
|
||||
defaultValue={androidHome}
|
||||
onChange={(v) => {
|
||||
this.setState({
|
||||
updatedSettings: {
|
||||
...this.state.updatedSettings,
|
||||
androidHome: v,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ToggledSection>
|
||||
<ToggledSection
|
||||
label="iOS Developer"
|
||||
toggled={enableIOS && this.props.platform === 'darwin'}
|
||||
onChange={(v) => {
|
||||
this.setState({
|
||||
updatedSettings: {...this.state.updatedSettings, enableIOS: v},
|
||||
});
|
||||
}}>
|
||||
{' '}
|
||||
{this.props.platform === 'darwin' && (
|
||||
<ConfigText
|
||||
content={'Use "xcode-select" to switch between Xcode versions'}
|
||||
/>
|
||||
)}
|
||||
{this.props.platform !== 'darwin' && (
|
||||
<ConfigText
|
||||
content={
|
||||
'iOS development has limited functionality on non-MacOS devices'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<ToggledSection
|
||||
label="Enable physical iOS devices"
|
||||
toggled={enablePhysicalIOS}
|
||||
frozen={false}
|
||||
onChange={(v) => {
|
||||
this.setState({
|
||||
updatedSettings: {
|
||||
...this.state.updatedSettings,
|
||||
enablePhysicalIOS: v,
|
||||
},
|
||||
});
|
||||
}}>
|
||||
<FilePathConfigField
|
||||
label="IDB binary location"
|
||||
defaultValue={idbPath}
|
||||
isRegularFile
|
||||
onChange={(v) => {
|
||||
this.setState({
|
||||
updatedSettings: {...this.state.updatedSettings, idbPath: v},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</ToggledSection>
|
||||
</ToggledSection>
|
||||
<LauncherSettingsPanel
|
||||
isPrefetchingEnabled={enablePrefetching}
|
||||
onEnablePrefetchingChange={(v) => {
|
||||
this.setState({
|
||||
updatedSettings: {
|
||||
...this.state.updatedSettings,
|
||||
enablePrefetching: v,
|
||||
},
|
||||
});
|
||||
}}
|
||||
isLocalPinIgnored={this.state.updatedLauncherSettings.ignoreLocalPin}
|
||||
onIgnoreLocalPinChange={(v) => {
|
||||
this.setState({
|
||||
updatedLauncherSettings: {
|
||||
...this.state.updatedLauncherSettings,
|
||||
ignoreLocalPin: v,
|
||||
},
|
||||
});
|
||||
}}
|
||||
releaseChannel={this.state.updatedLauncherSettings.releaseChannel}
|
||||
onReleaseChannelChange={(v) => {
|
||||
this.setState({
|
||||
updatedLauncherSettings: {
|
||||
...this.state.updatedLauncherSettings,
|
||||
releaseChannel: v,
|
||||
},
|
||||
forcedRestartLauncherSettings: {
|
||||
...this.state.forcedRestartLauncherSettings,
|
||||
releaseChannel: v,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<ToggledSection
|
||||
label="Suppress error notifications send from client plugins"
|
||||
toggled={suppressPluginErrors}
|
||||
onChange={(enabled) => {
|
||||
this.setState((prevState) => ({
|
||||
updatedSettings: {
|
||||
...prevState.updatedSettings,
|
||||
suppressPluginErrors: enabled,
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Layout.Container style={{paddingLeft: 15, paddingBottom: 10}}>
|
||||
Theme Selection
|
||||
<Radio.Group
|
||||
value={darkMode}
|
||||
onChange={(event) => {
|
||||
this.setState((prevState) => ({
|
||||
updatedSettings: {
|
||||
...prevState.updatedSettings,
|
||||
darkMode: event.target.value,
|
||||
},
|
||||
}));
|
||||
}}>
|
||||
<Radio.Button value="dark">Dark</Radio.Button>
|
||||
<Radio.Button value="light">Light</Radio.Button>
|
||||
<Radio.Button value="system">Use System Setting</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Layout.Container>
|
||||
<ToggledSection
|
||||
label="React Native keyboard shortcuts"
|
||||
toggled={reactNative.shortcuts.enabled}
|
||||
onChange={(enabled) => {
|
||||
this.setState((prevState) => ({
|
||||
updatedSettings: {
|
||||
...prevState.updatedSettings,
|
||||
reactNative: {
|
||||
...prevState.updatedSettings.reactNative,
|
||||
shortcuts: {
|
||||
...prevState.updatedSettings.reactNative.shortcuts,
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}}>
|
||||
<KeyboardShortcutInput
|
||||
label="Reload application"
|
||||
value={reactNative.shortcuts.reload}
|
||||
onChange={(reload) => {
|
||||
this.setState((prevState) => ({
|
||||
updatedSettings: {
|
||||
...prevState.updatedSettings,
|
||||
reactNative: {
|
||||
...prevState.updatedSettings.reactNative,
|
||||
shortcuts: {
|
||||
...prevState.updatedSettings.reactNative.shortcuts,
|
||||
reload,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<KeyboardShortcutInput
|
||||
label="Open developer menu"
|
||||
value={reactNative.shortcuts.openDevMenu}
|
||||
onChange={(openDevMenu) => {
|
||||
this.setState((prevState) => ({
|
||||
updatedSettings: {
|
||||
...prevState.updatedSettings,
|
||||
reactNative: {
|
||||
...prevState.updatedSettings.reactNative,
|
||||
shortcuts: {
|
||||
...prevState.updatedSettings.reactNative.shortcuts,
|
||||
openDevMenu,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</ToggledSection>
|
||||
<Layout.Right center>
|
||||
<span>Reset all new user tooltips</span>
|
||||
<ResetTooltips />
|
||||
</Layout.Right>
|
||||
<Layout.Right center>
|
||||
<span>Reset all local storage based state</span>
|
||||
<ResetLocalState />
|
||||
</Layout.Right>
|
||||
</Layout.Container>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<Button onClick={this.props.onHide}>Cancel</Button>
|
||||
<Button
|
||||
disabled={settingsPristine || forcedRestart}
|
||||
onClick={this.applyChangesWithoutRestart}>
|
||||
Apply
|
||||
</Button>
|
||||
<Button
|
||||
disabled={settingsPristine}
|
||||
type="primary"
|
||||
onClick={this.applyChanges}>
|
||||
Apply and Restart
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
return this.props.noModal ? (
|
||||
<>
|
||||
{contents}
|
||||
{footer}
|
||||
</>
|
||||
) : (
|
||||
this.renderSandyContainer(contents, footer)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
|
||||
({settingsState, launcherSettingsState}) => ({
|
||||
settings: settingsState,
|
||||
launcherSettings: launcherSettingsState,
|
||||
}),
|
||||
{updateSettings, updateLauncherSettings},
|
||||
)(withTrackingScope(SettingsSheet));
|
||||
|
||||
function ResetTooltips() {
|
||||
const nuxManager = useContext(_NuxManagerContext);
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={() => {
|
||||
nuxManager.resetHints();
|
||||
}}>
|
||||
Reset hints
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ResetLocalState() {
|
||||
return (
|
||||
<Button
|
||||
danger
|
||||
onClick={() => {
|
||||
window.localStorage.clear();
|
||||
message.success('Local storage state cleared');
|
||||
}}>
|
||||
Reset all state
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
63
desktop/flipper-ui-core/src/chrome/ShareSheetErrorList.tsx
Normal file
63
desktop/flipper-ui-core/src/chrome/ShareSheetErrorList.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* 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, {PureComponent} from 'react';
|
||||
import {Text, styled, Info, VBox} from '../ui';
|
||||
|
||||
type Props = {
|
||||
errors: Array<Error>;
|
||||
title: string;
|
||||
type: 'info' | 'spinning' | 'warning' | 'error';
|
||||
};
|
||||
|
||||
const ErrorMessage = styled(Text)({
|
||||
display: 'block',
|
||||
marginTop: 6,
|
||||
wordBreak: 'break-all',
|
||||
whiteSpace: 'pre-line',
|
||||
lineHeight: 1.35,
|
||||
});
|
||||
|
||||
const Title = styled(Text)({
|
||||
marginBottom: 6,
|
||||
});
|
||||
|
||||
export function formatError(e: Error): string {
|
||||
const estr = e.toString();
|
||||
|
||||
if (estr === '[object Object]') {
|
||||
try {
|
||||
return JSON.stringify(e);
|
||||
} catch (e) {
|
||||
return '<unrepresentable error>';
|
||||
}
|
||||
}
|
||||
|
||||
return estr;
|
||||
}
|
||||
|
||||
export default class Popover extends PureComponent<Props> {
|
||||
render() {
|
||||
if (this.props.errors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<VBox scrollable maxHeight={300}>
|
||||
<Info type={this.props.type}>
|
||||
<Title bold>{this.props.title}</Title>
|
||||
{this.props.errors.map((e: Error, index) => (
|
||||
<ErrorMessage code key={index}>
|
||||
{formatError(e)}
|
||||
</ErrorMessage>
|
||||
))}
|
||||
</Info>
|
||||
</VBox>
|
||||
);
|
||||
}
|
||||
}
|
||||
211
desktop/flipper-ui-core/src/chrome/ShareSheetExportFile.tsx
Normal file
211
desktop/flipper-ui-core/src/chrome/ShareSheetExportFile.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* 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, Button, styled, Text, FlexRow, Spacer} from '../ui';
|
||||
import React, {Component} from 'react';
|
||||
import {reportPlatformFailures} from 'flipper-common';
|
||||
import {performance} from 'perf_hooks';
|
||||
import {Logger} from 'flipper-common';
|
||||
import {IdlerImpl} from '../utils/Idler';
|
||||
import {
|
||||
exportStoreToFile,
|
||||
EXPORT_FLIPPER_TRACE_EVENT,
|
||||
displayFetchMetadataErrors,
|
||||
} from '../utils/exportData';
|
||||
import ShareSheetErrorList from './ShareSheetErrorList';
|
||||
import ShareSheetPendingDialog from './ShareSheetPendingDialog';
|
||||
import {ReactReduxContext, ReactReduxContextValue} from 'react-redux';
|
||||
import {MiddlewareAPI} from '../reducers/index';
|
||||
import {Modal} from 'antd';
|
||||
|
||||
const Container = styled(FlexColumn)({
|
||||
padding: 20,
|
||||
width: 500,
|
||||
});
|
||||
|
||||
const ErrorMessage = styled(Text)({
|
||||
display: 'block',
|
||||
marginTop: 6,
|
||||
wordBreak: 'break-all',
|
||||
whiteSpace: 'pre-line',
|
||||
lineHeight: 1.35,
|
||||
});
|
||||
|
||||
const Title = styled(Text)({
|
||||
marginBottom: 6,
|
||||
});
|
||||
|
||||
const InfoText = styled(Text)({
|
||||
lineHeight: 1.35,
|
||||
marginBottom: 15,
|
||||
});
|
||||
|
||||
type Props = {
|
||||
onHide: () => void;
|
||||
file: string;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
type State = {
|
||||
fetchMetaDataErrors: {
|
||||
[plugin: string]: Error;
|
||||
} | null;
|
||||
result:
|
||||
| {
|
||||
kind: 'success';
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
error: Error;
|
||||
}
|
||||
| {
|
||||
kind: 'pending';
|
||||
};
|
||||
statusUpdate: string | null;
|
||||
};
|
||||
|
||||
export default class ShareSheetExportFile extends Component<Props, State> {
|
||||
static contextType: React.Context<ReactReduxContextValue> = ReactReduxContext;
|
||||
|
||||
state: State = {
|
||||
fetchMetaDataErrors: null,
|
||||
result: {kind: 'pending'},
|
||||
statusUpdate: null,
|
||||
};
|
||||
|
||||
get store(): MiddlewareAPI {
|
||||
return this.context.store;
|
||||
}
|
||||
|
||||
idler = new IdlerImpl();
|
||||
|
||||
async componentDidMount() {
|
||||
const mark = 'shareSheetExportFile';
|
||||
performance.mark(mark);
|
||||
try {
|
||||
if (!this.props.file) {
|
||||
return;
|
||||
}
|
||||
const {fetchMetaDataErrors} = await reportPlatformFailures(
|
||||
exportStoreToFile(
|
||||
this.props.file,
|
||||
this.store,
|
||||
false,
|
||||
this.idler,
|
||||
(msg: string) => {
|
||||
this.setState({statusUpdate: msg});
|
||||
},
|
||||
),
|
||||
`${EXPORT_FLIPPER_TRACE_EVENT}:UI_FILE`,
|
||||
);
|
||||
this.setState({
|
||||
fetchMetaDataErrors,
|
||||
result: fetchMetaDataErrors
|
||||
? {error: JSON.stringify(fetchMetaDataErrors) as any, kind: 'error'}
|
||||
: {kind: 'success'},
|
||||
});
|
||||
this.props.logger.trackTimeSince(mark, 'export:file-success');
|
||||
} catch (err) {
|
||||
const result: {
|
||||
kind: 'error';
|
||||
error: Error;
|
||||
} = {
|
||||
kind: 'error',
|
||||
error: err,
|
||||
};
|
||||
// Show the error in UI.
|
||||
this.setState({result});
|
||||
this.props.logger.trackTimeSince(mark, 'export:file-error', result);
|
||||
console.error('Failed to export to file: ', err);
|
||||
}
|
||||
}
|
||||
|
||||
renderSuccess() {
|
||||
const {title, errorArray} = displayFetchMetadataErrors(
|
||||
this.state.fetchMetaDataErrors,
|
||||
);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<FlexColumn>
|
||||
<Title bold>Data Exported Successfully</Title>
|
||||
<InfoText>
|
||||
When sharing your Flipper data, consider that the captured data
|
||||
might contain sensitive information like access tokens used in
|
||||
network requests.
|
||||
</InfoText>
|
||||
<ShareSheetErrorList
|
||||
errors={errorArray}
|
||||
title={title}
|
||||
type={'warning'}
|
||||
/>
|
||||
</FlexColumn>
|
||||
<FlexRow>
|
||||
<Spacer />
|
||||
<Button compact padded onClick={() => this.cancelAndHide()}>
|
||||
Close
|
||||
</Button>
|
||||
</FlexRow>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
renderError(result: {kind: 'error'; error: Error}) {
|
||||
return (
|
||||
<Container>
|
||||
<Title bold>Error</Title>
|
||||
<ErrorMessage code>
|
||||
{result.error.message || 'File could not be saved.'}
|
||||
</ErrorMessage>
|
||||
<FlexRow>
|
||||
<Spacer />
|
||||
<Button compact padded onClick={() => this.cancelAndHide()}>
|
||||
Close
|
||||
</Button>
|
||||
</FlexRow>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
renderPending(statusUpdate: string | null) {
|
||||
return (
|
||||
<ShareSheetPendingDialog
|
||||
width={500}
|
||||
statusUpdate={statusUpdate}
|
||||
statusMessage="Creating Flipper Export..."
|
||||
onCancel={() => this.cancelAndHide()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
cancelAndHide = () => {
|
||||
this.props.onHide();
|
||||
this.idler.cancel();
|
||||
};
|
||||
|
||||
renderStatus() {
|
||||
const {result, statusUpdate} = this.state;
|
||||
switch (result.kind) {
|
||||
case 'success':
|
||||
return this.renderSuccess();
|
||||
case 'error':
|
||||
return this.renderError(result);
|
||||
case 'pending':
|
||||
return this.renderPending(statusUpdate);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Modal visible onCancel={this.cancelAndHide} footer={null}>
|
||||
{this.renderStatus()}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
227
desktop/flipper-ui-core/src/chrome/ShareSheetExportUrl.tsx
Normal file
227
desktop/flipper-ui-core/src/chrome/ShareSheetExportUrl.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* 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, styled, Text, FlexRow, Spacer, Input} from '../ui';
|
||||
import React, {Component} from 'react';
|
||||
import {ReactReduxContext, ReactReduxContextValue} from 'react-redux';
|
||||
import {Logger} from 'flipper-common';
|
||||
import {IdlerImpl} from '../utils/Idler';
|
||||
import {
|
||||
shareFlipperData,
|
||||
DataExportResult,
|
||||
DataExportError,
|
||||
} from '../fb-stubs/user';
|
||||
import {
|
||||
exportStore,
|
||||
EXPORT_FLIPPER_TRACE_EVENT,
|
||||
displayFetchMetadataErrors,
|
||||
} from '../utils/exportData';
|
||||
import ShareSheetErrorList from './ShareSheetErrorList';
|
||||
import {reportPlatformFailures} from 'flipper-common';
|
||||
import {performance} from 'perf_hooks';
|
||||
import ShareSheetPendingDialog from './ShareSheetPendingDialog';
|
||||
import {getLogger} from 'flipper-common';
|
||||
import {resetSupportFormV2State} from '../reducers/supportForm';
|
||||
import {MiddlewareAPI} from '../reducers/index';
|
||||
import {getFlipperLib, Layout} from 'flipper-plugin';
|
||||
import {Button, Modal} from 'antd';
|
||||
|
||||
export const SHARE_FLIPPER_TRACE_EVENT = 'share-flipper-link';
|
||||
|
||||
const Copy = styled(Input)({
|
||||
marginRight: 0,
|
||||
marginBottom: 15,
|
||||
});
|
||||
|
||||
const InfoText = styled(Text)({
|
||||
lineHeight: 1.35,
|
||||
marginBottom: 15,
|
||||
});
|
||||
|
||||
const Title = styled(Text)({
|
||||
marginBottom: 6,
|
||||
});
|
||||
|
||||
const ErrorMessage = styled(Text)({
|
||||
display: 'block',
|
||||
marginTop: 6,
|
||||
wordBreak: 'break-all',
|
||||
whiteSpace: 'pre-line',
|
||||
lineHeight: 1.35,
|
||||
});
|
||||
|
||||
type Props = {
|
||||
onHide: () => any;
|
||||
logger: Logger;
|
||||
};
|
||||
|
||||
type State = {
|
||||
fetchMetaDataErrors: {
|
||||
[plugin: string]: Error;
|
||||
} | null;
|
||||
result: DataExportError | DataExportResult | null;
|
||||
statusUpdate: string | null;
|
||||
};
|
||||
|
||||
export default class ShareSheetExportUrl extends Component<Props, State> {
|
||||
static contextType: React.Context<ReactReduxContextValue> = ReactReduxContext;
|
||||
|
||||
state: State = {
|
||||
fetchMetaDataErrors: null,
|
||||
result: null,
|
||||
statusUpdate: null,
|
||||
};
|
||||
|
||||
get store(): MiddlewareAPI {
|
||||
return this.context.store;
|
||||
}
|
||||
|
||||
idler = new IdlerImpl();
|
||||
|
||||
async componentDidMount() {
|
||||
const mark = 'shareSheetExportUrl';
|
||||
performance.mark(mark);
|
||||
try {
|
||||
const statusUpdate = (msg: string) => {
|
||||
this.setState({statusUpdate: msg});
|
||||
};
|
||||
const {serializedString, fetchMetaDataErrors} =
|
||||
await reportPlatformFailures(
|
||||
exportStore(this.store, false, this.idler, statusUpdate),
|
||||
`${EXPORT_FLIPPER_TRACE_EVENT}:UI_LINK`,
|
||||
);
|
||||
const uploadMarker = `${EXPORT_FLIPPER_TRACE_EVENT}:upload`;
|
||||
performance.mark(uploadMarker);
|
||||
statusUpdate('Uploading Flipper Export...');
|
||||
const result = await reportPlatformFailures(
|
||||
shareFlipperData(serializedString),
|
||||
`${SHARE_FLIPPER_TRACE_EVENT}`,
|
||||
);
|
||||
|
||||
if ((result as DataExportError).error != undefined) {
|
||||
const res = result as DataExportError;
|
||||
const err = new Error(res.error);
|
||||
err.stack = res.stacktrace;
|
||||
throw err;
|
||||
}
|
||||
getLogger().trackTimeSince(uploadMarker, uploadMarker, {
|
||||
plugins: this.store.getState().plugins.selectedPlugins,
|
||||
});
|
||||
const flipperUrl = (result as DataExportResult).flipperUrl;
|
||||
if (flipperUrl) {
|
||||
getFlipperLib().writeTextToClipboard(String(flipperUrl));
|
||||
new Notification('Shareable Flipper Export created', {
|
||||
body: 'URL copied to clipboard',
|
||||
requireInteraction: true,
|
||||
});
|
||||
}
|
||||
this.setState({fetchMetaDataErrors, result});
|
||||
this.store.dispatch(resetSupportFormV2State());
|
||||
this.props.logger.trackTimeSince(mark, 'export:url-success');
|
||||
} catch (e) {
|
||||
const result: DataExportError = {
|
||||
error_class: 'EXPORT_ERROR',
|
||||
error: e,
|
||||
stacktrace: '',
|
||||
};
|
||||
if (e instanceof Error) {
|
||||
result.error = e.message;
|
||||
result.stacktrace = e.stack || '';
|
||||
}
|
||||
// Show the error in UI.
|
||||
this.setState({result});
|
||||
this.props.logger.trackTimeSince(mark, 'export:url-error', result);
|
||||
console.error('Failed to export to flipper trace', e);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
const {result} = this.state;
|
||||
if (!result || !(result as DataExportResult).flipperUrl) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
cancelAndHide = () => {
|
||||
this.props.onHide();
|
||||
this.idler.cancel();
|
||||
};
|
||||
|
||||
renderPending(statusUpdate: string | null) {
|
||||
return (
|
||||
<Modal visible onCancel={this.cancelAndHide} footer={null}>
|
||||
<ShareSheetPendingDialog
|
||||
width={500}
|
||||
statusUpdate={statusUpdate}
|
||||
statusMessage="Uploading Flipper Export..."
|
||||
onCancel={this.cancelAndHide}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {result, statusUpdate, fetchMetaDataErrors} = this.state;
|
||||
if (!result) {
|
||||
return this.renderPending(statusUpdate);
|
||||
}
|
||||
|
||||
const {title, errorArray} = displayFetchMetadataErrors(fetchMetaDataErrors);
|
||||
return (
|
||||
<Modal visible onCancel={this.cancelAndHide} footer={null}>
|
||||
<Layout.Container>
|
||||
<>
|
||||
<FlexColumn>
|
||||
{(result as DataExportResult).flipperUrl ? (
|
||||
<>
|
||||
<Title bold>Data Upload Successful</Title>
|
||||
<InfoText>
|
||||
Flipper's data was successfully uploaded. This URL can be
|
||||
used to share with other Flipper users. Opening it will
|
||||
import the data from your export.
|
||||
</InfoText>
|
||||
<Copy
|
||||
value={(result as DataExportResult).flipperUrl}
|
||||
readOnly
|
||||
/>
|
||||
<InfoText>
|
||||
When sharing your Flipper link, consider that the captured
|
||||
data might contain sensitve information like access tokens
|
||||
used in network requests.
|
||||
</InfoText>
|
||||
<ShareSheetErrorList
|
||||
errors={errorArray}
|
||||
title={title}
|
||||
type={'warning'}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Title bold>
|
||||
{(result as DataExportError).error_class || 'Error'}
|
||||
</Title>
|
||||
<ErrorMessage code>
|
||||
{(result as DataExportError).error ||
|
||||
'The data could not be uploaded'}
|
||||
</ErrorMessage>
|
||||
</>
|
||||
)}
|
||||
</FlexColumn>
|
||||
<FlexRow>
|
||||
<Spacer />
|
||||
<Button type="primary" onClick={this.cancelAndHide}>
|
||||
Close
|
||||
</Button>
|
||||
</FlexRow>
|
||||
</>
|
||||
</Layout.Container>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Button, Typography} from 'antd';
|
||||
import {Layout, Spinner} from 'flipper-plugin';
|
||||
import React from 'react';
|
||||
|
||||
const {Text} = Typography;
|
||||
|
||||
export default function (props: {
|
||||
statusMessage: string;
|
||||
statusUpdate: string | null;
|
||||
hideNavButtons?: boolean;
|
||||
onCancel?: () => void;
|
||||
width?: number;
|
||||
}) {
|
||||
return (
|
||||
<Layout.Container style={{width: props.width, textAlign: 'center'}}>
|
||||
<Spinner size={30} />
|
||||
{props.statusUpdate && props.statusUpdate.length > 0 ? (
|
||||
<Text strong>{props.statusUpdate}</Text>
|
||||
) : (
|
||||
<Text strong>{props.statusMessage}</Text>
|
||||
)}
|
||||
{!props.hideNavButtons && props.onCancel && (
|
||||
<Layout.Right>
|
||||
<div />
|
||||
<Button
|
||||
onClick={() => {
|
||||
props.onCancel && props.onCancel();
|
||||
}}>
|
||||
Cancel
|
||||
</Button>
|
||||
</Layout.Right>
|
||||
)}
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
136
desktop/flipper-ui-core/src/chrome/UpdateIndicator.tsx
Normal file
136
desktop/flipper-ui-core/src/chrome/UpdateIndicator.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {notification, Typography} from 'antd';
|
||||
import isProduction from '../utils/isProduction';
|
||||
import {reportPlatformFailures} from 'flipper-common';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import fbConfig from '../fb-stubs/config';
|
||||
import {useStore} from '../utils/useStore';
|
||||
import {getAppVersion} from '../utils/info';
|
||||
import {checkForUpdate} from '../fb-stubs/checkForUpdate';
|
||||
import ReleaseChannel from '../ReleaseChannel';
|
||||
|
||||
export type VersionCheckResult =
|
||||
| {
|
||||
kind: 'update-available';
|
||||
url: string;
|
||||
version: string;
|
||||
}
|
||||
| {
|
||||
kind: 'up-to-date';
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
msg: string;
|
||||
};
|
||||
|
||||
export default function UpdateIndicator() {
|
||||
const [versionCheckResult, setVersionCheckResult] =
|
||||
useState<VersionCheckResult>({kind: 'up-to-date'});
|
||||
const launcherMsg = useStore((state) => state.application.launcherMsg);
|
||||
|
||||
// Effect to show notification if details change
|
||||
useEffect(() => {
|
||||
switch (versionCheckResult.kind) {
|
||||
case 'up-to-date':
|
||||
break;
|
||||
case 'update-available':
|
||||
console.log(
|
||||
`Flipper update available: ${versionCheckResult.version} at ${versionCheckResult.url}`,
|
||||
);
|
||||
notification.info({
|
||||
placement: 'bottomLeft',
|
||||
key: 'flipperupdatecheck',
|
||||
message: 'Update available',
|
||||
description: getUpdateAvailableMessage(versionCheckResult),
|
||||
duration: null, // no auto close
|
||||
});
|
||||
break;
|
||||
case 'error':
|
||||
console.warn(
|
||||
`Failed to check for Flipper update: ${versionCheckResult.msg}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}, [versionCheckResult]);
|
||||
|
||||
// trigger the update check, unless there is a launcher message already
|
||||
useEffect(() => {
|
||||
const version = getAppVersion();
|
||||
if (launcherMsg && launcherMsg.message) {
|
||||
if (launcherMsg.severity === 'error') {
|
||||
notification.error({
|
||||
placement: 'bottomLeft',
|
||||
key: 'launchermsg',
|
||||
message: 'Launch problem',
|
||||
description: launcherMsg.message,
|
||||
duration: null,
|
||||
});
|
||||
} else {
|
||||
notification.warning({
|
||||
placement: 'bottomLeft',
|
||||
key: 'launchermsg',
|
||||
message: 'Flipper version warning',
|
||||
description: launcherMsg.message,
|
||||
duration: null,
|
||||
});
|
||||
}
|
||||
} else if (version && isProduction()) {
|
||||
reportPlatformFailures(
|
||||
checkForUpdate(version).then((res) => {
|
||||
if (res.kind === 'error') {
|
||||
console.warn('Version check failure: ', res);
|
||||
setVersionCheckResult({
|
||||
kind: 'error',
|
||||
msg: res.msg,
|
||||
});
|
||||
} else {
|
||||
setVersionCheckResult(res);
|
||||
}
|
||||
}),
|
||||
'publicVersionCheck',
|
||||
);
|
||||
}
|
||||
}, [launcherMsg]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getUpdateAvailableMessage(versionCheckResult: {
|
||||
url: string;
|
||||
version: string;
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<>
|
||||
Flipper version {versionCheckResult.version} is now available.
|
||||
{fbConfig.isFBBuild ? (
|
||||
fbConfig.getReleaseChannel() === ReleaseChannel.INSIDERS ? (
|
||||
<> Restart Flipper to update to the latest version.</>
|
||||
) : (
|
||||
<>
|
||||
{' '}
|
||||
Run <code>arc pull</code> (optionally with <code>--latest</code>) in{' '}
|
||||
<code>~/fbsource</code> and restart Flipper to update to the latest
|
||||
version.
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{' '}
|
||||
Click to{' '}
|
||||
<Typography.Link href={versionCheckResult.url}>
|
||||
download
|
||||
</Typography.Link>
|
||||
.
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
101
desktop/flipper-ui-core/src/chrome/VideoRecordingButton.tsx
Normal file
101
desktop/flipper-ui-core/src/chrome/VideoRecordingButton.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* 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, {Component} from 'react';
|
||||
import BaseDevice from '../devices/BaseDevice';
|
||||
import {Button, Glyph, colors} from '../ui';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
type OwnProps = {
|
||||
recordingFinished: (path: string | null) => void;
|
||||
};
|
||||
|
||||
type StateFromProps = {
|
||||
selectedDevice: BaseDevice | null | undefined;
|
||||
};
|
||||
|
||||
type DispatchFromProps = {};
|
||||
|
||||
type State = {
|
||||
recording: boolean;
|
||||
recordingEnabled: boolean;
|
||||
};
|
||||
type Props = OwnProps & StateFromProps & DispatchFromProps;
|
||||
|
||||
export default class VideoRecordingButton extends Component<Props, State> {
|
||||
state: State = {
|
||||
recording: false,
|
||||
recordingEnabled: true,
|
||||
};
|
||||
|
||||
startRecording = async () => {
|
||||
const {selectedDevice} = this.props;
|
||||
if (!selectedDevice) {
|
||||
return;
|
||||
}
|
||||
|
||||
const flipperDirectory = path.join(os.homedir(), '.flipper');
|
||||
const fileName = `screencap-${new Date()
|
||||
.toISOString()
|
||||
.replace(/:/g, '')}.mp4`;
|
||||
const videoPath = path.join(flipperDirectory, fileName);
|
||||
this.setState({
|
||||
recording: true,
|
||||
});
|
||||
selectedDevice.startScreenCapture(videoPath).catch((e) => {
|
||||
console.error('Screen recording failed:', e);
|
||||
this.setState({
|
||||
recording: false,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
stopRecording = async () => {
|
||||
const {selectedDevice} = this.props;
|
||||
if (!selectedDevice) {
|
||||
return;
|
||||
}
|
||||
const path = await selectedDevice.stopScreenCapture();
|
||||
this.setState({
|
||||
recording: false,
|
||||
});
|
||||
this.props.recordingFinished(path);
|
||||
};
|
||||
|
||||
onRecordingClicked = () => {
|
||||
if (this.state.recording) {
|
||||
this.stopRecording();
|
||||
} else {
|
||||
this.startRecording();
|
||||
}
|
||||
};
|
||||
render() {
|
||||
const {recordingEnabled} = this.state;
|
||||
const {selectedDevice} = this.props;
|
||||
return (
|
||||
<Button
|
||||
compact
|
||||
onClick={this.onRecordingClicked}
|
||||
pulse={this.state.recording}
|
||||
selected={this.state.recording}
|
||||
title="Make Screen Recording"
|
||||
disabled={!selectedDevice || !recordingEnabled}
|
||||
type={this.state.recording ? 'danger' : 'primary'}>
|
||||
<Glyph
|
||||
name={this.state.recording ? 'stop-playback' : 'camcorder'}
|
||||
color={this.state.recording ? colors.red : colors.white}
|
||||
variant="filled"
|
||||
style={{marginRight: 8}}
|
||||
/>
|
||||
{this.state.recording ? 'Recording...' : 'Start Recording'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
hasNewChangesToShow,
|
||||
getRecentChangelog,
|
||||
markChangelogRead,
|
||||
} from '../ChangelogSheet';
|
||||
|
||||
class StubStorage {
|
||||
data: Record<string, string> = {};
|
||||
|
||||
setItem(key: string, value: string) {
|
||||
this.data[key] = value;
|
||||
}
|
||||
|
||||
getItem(key: string) {
|
||||
return this.data[key];
|
||||
}
|
||||
}
|
||||
|
||||
const changelog = `
|
||||
|
||||
# Version 2.0
|
||||
|
||||
* Nice feature one
|
||||
* Important fix
|
||||
|
||||
# Version 1.0
|
||||
|
||||
* Not very exciting actually
|
||||
|
||||
`;
|
||||
|
||||
describe('ChangelogSheet', () => {
|
||||
let storage!: Storage;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new StubStorage() as any;
|
||||
});
|
||||
|
||||
test('without storage, should show changes', () => {
|
||||
expect(hasNewChangesToShow(undefined, changelog)).toBe(false);
|
||||
expect(getRecentChangelog(storage, changelog)).toEqual(changelog.trim());
|
||||
expect(hasNewChangesToShow(storage, changelog)).toBe(true);
|
||||
});
|
||||
|
||||
test('with last header, should not show changes', () => {
|
||||
markChangelogRead(storage, changelog);
|
||||
expect(storage.data).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"FlipperChangelogStatus": "{\\"lastHeader\\":\\"# Version 2.0\\"}",
|
||||
}
|
||||
`);
|
||||
expect(hasNewChangesToShow(storage, changelog)).toBe(false);
|
||||
|
||||
const newChangelog = `
|
||||
# Version 3.0
|
||||
|
||||
* Cool!
|
||||
|
||||
# Version 2.5
|
||||
|
||||
* This is visible as well
|
||||
|
||||
${changelog}
|
||||
`;
|
||||
|
||||
expect(hasNewChangesToShow(storage, newChangelog)).toBe(true);
|
||||
expect(getRecentChangelog(storage, newChangelog)).toMatchInlineSnapshot(`
|
||||
"# Version 3.0
|
||||
|
||||
* Cool!
|
||||
|
||||
# Version 2.5
|
||||
|
||||
* This is visible as well"
|
||||
`);
|
||||
markChangelogRead(storage, newChangelog);
|
||||
expect(storage.data).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"FlipperChangelogStatus": "{\\"lastHeader\\":\\"# Version 3.0\\"}",
|
||||
}
|
||||
`);
|
||||
expect(hasNewChangesToShow(storage, newChangelog)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {formatError} from '../ShareSheetErrorList';
|
||||
|
||||
test('normal error is formatted', () => {
|
||||
const e = new Error('something went wrong');
|
||||
expect(formatError(e)).toEqual('Error: something went wrong');
|
||||
});
|
||||
|
||||
test('objects are formatted', () => {
|
||||
const e: any = {iam: 'not an error'};
|
||||
expect(formatError(e)).toEqual('{"iam":"not an error"}');
|
||||
});
|
||||
|
||||
test('recursive data structures are not formatted', () => {
|
||||
const e: any = {b: null};
|
||||
e.b = e;
|
||||
expect(formatError(e)).toEqual('<unrepresentable error>');
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import ShareSheetPendingDialog from '../ShareSheetPendingDialog';
|
||||
import React from 'react';
|
||||
import renderer from 'react-test-renderer';
|
||||
import configureStore from 'redux-mock-store';
|
||||
|
||||
const mockStore = configureStore([])({application: {sessionId: 'mysession'}});
|
||||
import {Provider} from 'react-redux';
|
||||
|
||||
test('ShareSheetPendingDialog is rendered with status update', () => {
|
||||
const component = (
|
||||
<Provider store={mockStore}>
|
||||
<ShareSheetPendingDialog
|
||||
onCancel={() => {}}
|
||||
statusMessage="wubba lubba dub dub"
|
||||
statusUpdate="Update"
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
expect(renderer.create(component).toJSON()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('ShareSheetPendingDialog is rendered without status update', () => {
|
||||
const component = (
|
||||
<Provider store={mockStore}>
|
||||
<ShareSheetPendingDialog
|
||||
onCancel={() => {}}
|
||||
statusMessage="wubba lubba dub dub"
|
||||
statusUpdate={null}
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
|
||||
expect(renderer.create(component).toJSON()).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`ShareSheetPendingDialog is rendered with status update 1`] = `
|
||||
<div
|
||||
className="css-gzchr8-Container e1hsqii15"
|
||||
style={
|
||||
Object {
|
||||
"textAlign": "center",
|
||||
"width": undefined,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="ant-spin ant-spin-spinning"
|
||||
>
|
||||
<span
|
||||
aria-label="loading"
|
||||
className="anticon anticon-loading anticon-spin ant-spin-dot"
|
||||
role="img"
|
||||
style={
|
||||
Object {
|
||||
"fontSize": 30,
|
||||
}
|
||||
}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-icon="loading"
|
||||
fill="currentColor"
|
||||
focusable="false"
|
||||
height="1em"
|
||||
viewBox="0 0 1024 1024"
|
||||
width="1em"
|
||||
>
|
||||
<path
|
||||
d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="ant-typography"
|
||||
style={
|
||||
Object {
|
||||
"WebkitLineClamp": undefined,
|
||||
}
|
||||
}
|
||||
>
|
||||
<strong>
|
||||
Update
|
||||
</strong>
|
||||
</span>
|
||||
<div
|
||||
className="css-1knrt0j-SandySplitContainer e1hsqii10"
|
||||
>
|
||||
<div />
|
||||
<button
|
||||
className="ant-btn"
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`ShareSheetPendingDialog is rendered without status update 1`] = `
|
||||
<div
|
||||
className="css-gzchr8-Container e1hsqii15"
|
||||
style={
|
||||
Object {
|
||||
"textAlign": "center",
|
||||
"width": undefined,
|
||||
}
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="ant-spin ant-spin-spinning"
|
||||
>
|
||||
<span
|
||||
aria-label="loading"
|
||||
className="anticon anticon-loading anticon-spin ant-spin-dot"
|
||||
role="img"
|
||||
style={
|
||||
Object {
|
||||
"fontSize": 30,
|
||||
}
|
||||
}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-icon="loading"
|
||||
fill="currentColor"
|
||||
focusable="false"
|
||||
height="1em"
|
||||
viewBox="0 0 1024 1024"
|
||||
width="1em"
|
||||
>
|
||||
<path
|
||||
d="M988 548c-19.9 0-36-16.1-36-36 0-59.4-11.6-117-34.6-171.3a440.45 440.45 0 00-94.3-139.9 437.71 437.71 0 00-139.9-94.3C629 83.6 571.4 72 512 72c-19.9 0-36-16.1-36-36s16.1-36 36-36c69.1 0 136.2 13.5 199.3 40.3C772.3 66 827 103 874 150c47 47 83.9 101.8 109.7 162.7 26.7 63.1 40.2 130.2 40.2 199.3.1 19.9-16 36-35.9 36z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className="ant-typography"
|
||||
style={
|
||||
Object {
|
||||
"WebkitLineClamp": undefined,
|
||||
}
|
||||
}
|
||||
>
|
||||
<strong>
|
||||
wubba lubba dub dub
|
||||
</strong>
|
||||
</span>
|
||||
<div
|
||||
className="css-1knrt0j-SandySplitContainer e1hsqii10"
|
||||
>
|
||||
<div />
|
||||
<button
|
||||
className="ant-btn"
|
||||
onClick={[Function]}
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Cancel
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import * as React from 'react';
|
||||
import {act, render} from '@testing-library/react';
|
||||
|
||||
import {
|
||||
clearFlipperDebugMessages,
|
||||
FlipperMessages,
|
||||
getFlipperDebugMessages,
|
||||
MessageRow,
|
||||
registerFlipperDebugMessage,
|
||||
setFlipperMessageDebuggingEnabled,
|
||||
} from '../FlipperMessages';
|
||||
|
||||
const fixRowTimestamps = (r: MessageRow): MessageRow => ({
|
||||
...r,
|
||||
time: new Date(Date.UTC(0, 0, 0, 0, 0, 0)),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
clearFlipperDebugMessages();
|
||||
setFlipperMessageDebuggingEnabled(true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearFlipperDebugMessages();
|
||||
setFlipperMessageDebuggingEnabled(false);
|
||||
});
|
||||
|
||||
test('It can store rows', () => {
|
||||
registerFlipperDebugMessage({
|
||||
app: 'Flipper',
|
||||
direction: 'toFlipper:message',
|
||||
});
|
||||
|
||||
registerFlipperDebugMessage({
|
||||
app: 'FB4A',
|
||||
direction: 'toClient:call',
|
||||
device: 'Android Phone',
|
||||
payload: {hello: 'world'},
|
||||
});
|
||||
|
||||
setFlipperMessageDebuggingEnabled(false);
|
||||
|
||||
registerFlipperDebugMessage({
|
||||
app: 'FB4A',
|
||||
direction: 'toClient:call',
|
||||
device: 'Android PhoneTEst',
|
||||
payload: {hello: 'world'},
|
||||
});
|
||||
|
||||
expect(getFlipperDebugMessages().map(fixRowTimestamps))
|
||||
.toMatchInlineSnapshot(`
|
||||
Array [
|
||||
Object {
|
||||
"app": "Flipper",
|
||||
"direction": "toFlipper:message",
|
||||
"time": 1899-12-31T00:00:00.000Z,
|
||||
},
|
||||
Object {
|
||||
"app": "FB4A",
|
||||
"device": "Android Phone",
|
||||
"direction": "toClient:call",
|
||||
"payload": Object {
|
||||
"hello": "world",
|
||||
},
|
||||
"time": 1899-12-31T00:00:00.000Z,
|
||||
},
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test('It can clear', () => {
|
||||
registerFlipperDebugMessage({
|
||||
app: 'Flipper',
|
||||
direction: 'toFlipper:message',
|
||||
});
|
||||
|
||||
clearFlipperDebugMessages();
|
||||
expect(getFlipperDebugMessages()).toEqual([]);
|
||||
});
|
||||
|
||||
test('It can render empty', async () => {
|
||||
const renderer = render(<FlipperMessages />);
|
||||
|
||||
// Default message without any highlighted rows.
|
||||
expect(
|
||||
await renderer.findByText('Select a message to view details'),
|
||||
).not.toBeNull();
|
||||
renderer.unmount();
|
||||
});
|
||||
|
||||
test('It can render rows', async () => {
|
||||
const renderer = render(<FlipperMessages />);
|
||||
|
||||
act(() => {
|
||||
registerFlipperDebugMessage({
|
||||
time: new Date(0, 0, 0, 0, 0, 0),
|
||||
app: 'Flipper',
|
||||
direction: 'toFlipper:message',
|
||||
});
|
||||
|
||||
registerFlipperDebugMessage({
|
||||
time: new Date(0, 0, 0, 0, 0, 0),
|
||||
app: 'FB4A',
|
||||
direction: 'toClient:send',
|
||||
device: 'Android Phone',
|
||||
flipperInternalMethod: 'unique-string',
|
||||
payload: {hello: 'world'},
|
||||
});
|
||||
});
|
||||
|
||||
expect((await renderer.findByText('unique-string')).parentElement)
|
||||
.toMatchInlineSnapshot(`
|
||||
<div
|
||||
class="ant-dropdown-trigger css-1k3kr6b-TableBodyRowContainer e1luu51r1"
|
||||
>
|
||||
<div
|
||||
class="css-1vr131n-TableBodyColumnContainer e1luu51r0"
|
||||
width="14%"
|
||||
>
|
||||
00:00:00.000
|
||||
</div>
|
||||
<div
|
||||
class="css-1vr131n-TableBodyColumnContainer e1luu51r0"
|
||||
width="14%"
|
||||
>
|
||||
Android Phone
|
||||
</div>
|
||||
<div
|
||||
class="css-1vr131n-TableBodyColumnContainer e1luu51r0"
|
||||
width="14%"
|
||||
>
|
||||
FB4A
|
||||
</div>
|
||||
<div
|
||||
class="css-1vr131n-TableBodyColumnContainer e1luu51r0"
|
||||
width="14%"
|
||||
>
|
||||
unique-string
|
||||
</div>
|
||||
<div
|
||||
class="css-1vr131n-TableBodyColumnContainer e1luu51r0"
|
||||
width="14%"
|
||||
/>
|
||||
<div
|
||||
class="css-1vr131n-TableBodyColumnContainer e1luu51r0"
|
||||
width="14%"
|
||||
/>
|
||||
<div
|
||||
class="css-1vr131n-TableBodyColumnContainer e1luu51r0"
|
||||
width="14%"
|
||||
>
|
||||
toClient:send
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
renderer.unmount();
|
||||
});
|
||||
52
desktop/flipper-ui-core/src/chrome/fb-stubs/PluginInfo.tsx
Normal file
52
desktop/flipper-ui-core/src/chrome/fb-stubs/PluginInfo.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* 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 {useSelector} from 'react-redux';
|
||||
import {getActivePlugin} from '../../selectors/connections';
|
||||
import {ActivePluginListItem} from '../../utils/pluginUtils';
|
||||
import {Layout} from '../../ui';
|
||||
import {CenteredContainer} from '../../sandy-chrome/CenteredContainer';
|
||||
import {Typography} from 'antd';
|
||||
import {PluginActions} from '../PluginActions';
|
||||
import {CoffeeOutlined} from '@ant-design/icons';
|
||||
|
||||
const {Text, Title} = Typography;
|
||||
|
||||
export function PluginInfo() {
|
||||
const activePlugin = useSelector(getActivePlugin);
|
||||
if (activePlugin) {
|
||||
return <PluginMarketplace activePlugin={activePlugin} />;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function PluginMarketplace({
|
||||
activePlugin,
|
||||
}: {
|
||||
activePlugin: ActivePluginListItem;
|
||||
}) {
|
||||
return (
|
||||
<CenteredContainer>
|
||||
<Layout.Container center gap style={{maxWidth: 350}}>
|
||||
<CoffeeOutlined style={{fontSize: '24px'}} />
|
||||
<Title level={4}>
|
||||
Plugin '{activePlugin.details.title}' is {activePlugin.status}
|
||||
</Title>
|
||||
{activePlugin.status === 'unavailable' ? (
|
||||
<Text style={{textAlign: 'center'}}>{activePlugin.reason}.</Text>
|
||||
) : null}
|
||||
<Layout.Horizontal gap>
|
||||
<PluginActions activePlugin={activePlugin} type="link" />
|
||||
</Layout.Horizontal>
|
||||
</Layout.Container>
|
||||
</CenteredContainer>
|
||||
);
|
||||
}
|
||||
14
desktop/flipper-ui-core/src/chrome/fb-stubs/SignInSheet.tsx
Normal file
14
desktop/flipper-ui-core/src/chrome/fb-stubs/SignInSheet.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
export async function showLoginDialog(
|
||||
_initialToken: string = '',
|
||||
): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {PluginDetails} from 'flipper-plugin-lib';
|
||||
import {Layout} from 'flipper-plugin';
|
||||
import Client from '../../Client';
|
||||
import {TableBodyRow} from '../../ui/components/table/types';
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {Text, ManagedTable, styled, colors} from '../../ui';
|
||||
import StatusIndicator from '../../ui/components/StatusIndicator';
|
||||
import {State as Store} from '../../reducers';
|
||||
import {PluginDefinition} from '../../plugin';
|
||||
|
||||
const InfoText = styled(Text)({
|
||||
lineHeight: '130%',
|
||||
marginBottom: 8,
|
||||
});
|
||||
|
||||
const Ellipsis = styled(Text)({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
const TableContainer = styled.div({
|
||||
marginTop: 10,
|
||||
height: 480,
|
||||
});
|
||||
|
||||
const Lamp = (props: {on: boolean}) => (
|
||||
<StatusIndicator statusColor={props.on ? colors.lime : colors.red} />
|
||||
);
|
||||
|
||||
type StateFromProps = {
|
||||
gatekeepedPlugins: Array<PluginDetails>;
|
||||
disabledPlugins: Array<PluginDetails>;
|
||||
failedPlugins: Array<[PluginDetails, string]>;
|
||||
clients: Map<string, Client>;
|
||||
selectedDevice: string | null | undefined;
|
||||
devicePlugins: PluginDefinition[];
|
||||
clientPlugins: PluginDefinition[];
|
||||
};
|
||||
|
||||
type DispatchFromProps = {};
|
||||
|
||||
type OwnProps = {};
|
||||
|
||||
const COLUMNS = {
|
||||
lamp: {
|
||||
value: '',
|
||||
},
|
||||
name: {
|
||||
value: 'Name',
|
||||
},
|
||||
version: {
|
||||
value: 'Version',
|
||||
},
|
||||
status: {
|
||||
value: 'Status',
|
||||
},
|
||||
gk: {
|
||||
value: 'GK',
|
||||
},
|
||||
clients: {
|
||||
value: 'Supported by',
|
||||
},
|
||||
source: {
|
||||
value: 'Source',
|
||||
},
|
||||
};
|
||||
|
||||
const COLUMNS_SIZES = {
|
||||
lamp: 20,
|
||||
name: 'flex',
|
||||
version: 60,
|
||||
status: 110,
|
||||
gk: 120,
|
||||
clients: 90,
|
||||
source: 140,
|
||||
};
|
||||
|
||||
type Props = OwnProps & StateFromProps & DispatchFromProps;
|
||||
class PluginDebugger extends Component<Props> {
|
||||
buildRow(
|
||||
name: string,
|
||||
version: string,
|
||||
loaded: boolean,
|
||||
status: string,
|
||||
GKname: string | null | undefined,
|
||||
pluginPath: string,
|
||||
): TableBodyRow {
|
||||
return {
|
||||
key: name.toLowerCase(),
|
||||
columns: {
|
||||
lamp: {value: <Lamp on={loaded} />},
|
||||
name: {value: <Ellipsis>{name}</Ellipsis>},
|
||||
version: {value: <Ellipsis>{version}</Ellipsis>},
|
||||
status: {
|
||||
value: status ? <Ellipsis title={status}>{status}</Ellipsis> : null,
|
||||
},
|
||||
gk: {
|
||||
value: GKname && (
|
||||
<Ellipsis code title={GKname}>
|
||||
{GKname}
|
||||
</Ellipsis>
|
||||
),
|
||||
},
|
||||
clients: {
|
||||
value: this.getSupportedClients(name),
|
||||
},
|
||||
source: {
|
||||
value: (
|
||||
<Ellipsis code title={pluginPath}>
|
||||
{pluginPath}
|
||||
</Ellipsis>
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
getSupportedClients(id: string): string {
|
||||
return Array.from(this.props.clients.values())
|
||||
.reduce((acc: Array<string>, cv: Client) => {
|
||||
if (cv.plugins.has(id)) {
|
||||
acc.push(cv.query.app);
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
getRows(): Array<TableBodyRow> {
|
||||
const rows: Array<TableBodyRow> = [];
|
||||
|
||||
const externalPluginPath = (p: any) => (p.isBundled ? 'bundled' : p.entry);
|
||||
|
||||
this.props.gatekeepedPlugins.forEach((plugin) =>
|
||||
rows.push(
|
||||
this.buildRow(
|
||||
plugin.name,
|
||||
plugin.version,
|
||||
false,
|
||||
'GK disabled',
|
||||
plugin.gatekeeper,
|
||||
externalPluginPath(plugin),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
this.props.devicePlugins.forEach((plugin) =>
|
||||
rows.push(
|
||||
this.buildRow(
|
||||
plugin.id,
|
||||
plugin.version,
|
||||
true,
|
||||
'',
|
||||
plugin.gatekeeper,
|
||||
externalPluginPath(plugin),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
this.props.clientPlugins.forEach((plugin) =>
|
||||
rows.push(
|
||||
this.buildRow(
|
||||
plugin.id,
|
||||
plugin.version,
|
||||
true,
|
||||
'',
|
||||
plugin.gatekeeper,
|
||||
externalPluginPath(plugin),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
this.props.disabledPlugins.forEach((plugin) =>
|
||||
rows.push(
|
||||
this.buildRow(
|
||||
plugin.name,
|
||||
plugin.version,
|
||||
false,
|
||||
'disabled',
|
||||
null,
|
||||
externalPluginPath(plugin),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
this.props.failedPlugins.forEach(([plugin, status]) =>
|
||||
rows.push(
|
||||
this.buildRow(
|
||||
plugin.name,
|
||||
plugin.version,
|
||||
false,
|
||||
status,
|
||||
null,
|
||||
externalPluginPath(plugin),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return rows.sort((a, b) => (a.key < b.key ? -1 : 1));
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Layout.Container pad>
|
||||
<InfoText>The table lists all plugins known to Flipper.</InfoText>
|
||||
<TableContainer>
|
||||
<ManagedTable
|
||||
columns={COLUMNS}
|
||||
rows={this.getRows()}
|
||||
highlightableRows={false}
|
||||
columnSizes={COLUMNS_SIZES}
|
||||
/>
|
||||
</TableContainer>
|
||||
</Layout.Container>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
|
||||
({
|
||||
plugins: {
|
||||
devicePlugins,
|
||||
clientPlugins,
|
||||
gatekeepedPlugins,
|
||||
disabledPlugins,
|
||||
failedPlugins,
|
||||
},
|
||||
connections: {clients, selectedDevice},
|
||||
}) => ({
|
||||
devicePlugins: Array.from(devicePlugins.values()),
|
||||
clientPlugins: Array.from(clientPlugins.values()),
|
||||
gatekeepedPlugins,
|
||||
clients,
|
||||
disabledPlugins,
|
||||
failedPlugins,
|
||||
selectedDevice: selectedDevice && selectedDevice.serial,
|
||||
}),
|
||||
)(PluginDebugger);
|
||||
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {Layout, theme} from 'flipper-plugin';
|
||||
import {LoadingIndicator, TableRows, ManagedTable, Glyph} from '../../ui';
|
||||
import React, {useCallback, useState, useEffect} from 'react';
|
||||
import {reportPlatformFailures, reportUsage} from 'flipper-common';
|
||||
import reloadFlipper from '../../utils/reloadFlipper';
|
||||
import {registerInstalledPlugins} from '../../reducers/plugins';
|
||||
import {
|
||||
UpdateResult,
|
||||
getInstalledPlugins,
|
||||
getUpdatablePlugins,
|
||||
removePlugin,
|
||||
UpdatablePluginDetails,
|
||||
InstalledPluginDetails,
|
||||
} from 'flipper-plugin-lib';
|
||||
import {installPluginFromNpm} from 'flipper-plugin-lib';
|
||||
import {State as AppState} from '../../reducers';
|
||||
import {connect} from 'react-redux';
|
||||
import {Dispatch, Action} from 'redux';
|
||||
import PluginPackageInstaller from './PluginPackageInstaller';
|
||||
import {Toolbar} from 'flipper-plugin';
|
||||
import {Alert, Button, Input, Tooltip, Typography} from 'antd';
|
||||
|
||||
const {Text, Link} = Typography;
|
||||
|
||||
const TAG = 'PluginInstaller';
|
||||
|
||||
const columnSizes = {
|
||||
name: '25%',
|
||||
version: '10%',
|
||||
description: 'flex',
|
||||
install: '15%',
|
||||
};
|
||||
|
||||
const columns = {
|
||||
name: {
|
||||
value: 'Name',
|
||||
},
|
||||
version: {
|
||||
value: 'Version',
|
||||
},
|
||||
description: {
|
||||
value: 'Description',
|
||||
},
|
||||
install: {
|
||||
value: '',
|
||||
},
|
||||
};
|
||||
|
||||
type PropsFromState = {
|
||||
installedPlugins: Map<string, InstalledPluginDetails>;
|
||||
};
|
||||
|
||||
type DispatchFromProps = {
|
||||
refreshInstalledPlugins: () => void;
|
||||
};
|
||||
|
||||
type OwnProps = {
|
||||
autoHeight: boolean;
|
||||
};
|
||||
|
||||
type Props = OwnProps & PropsFromState & DispatchFromProps;
|
||||
|
||||
const defaultProps: OwnProps = {
|
||||
autoHeight: false,
|
||||
};
|
||||
|
||||
const PluginInstaller = function ({
|
||||
refreshInstalledPlugins,
|
||||
installedPlugins,
|
||||
autoHeight,
|
||||
}: Props) {
|
||||
const [restartRequired, setRestartRequired] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const onInstall = useCallback(async () => {
|
||||
refreshInstalledPlugins();
|
||||
setRestartRequired(true);
|
||||
}, [refreshInstalledPlugins]);
|
||||
|
||||
const rows = useNPMSearch(query, onInstall, installedPlugins);
|
||||
const restartApp = useCallback(() => {
|
||||
reloadFlipper();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Layout.Container gap height={500}>
|
||||
{restartRequired && (
|
||||
<Alert
|
||||
onClick={restartApp}
|
||||
type="error"
|
||||
message="To apply the changes, Flipper needs to reload. Click here to reload!"
|
||||
style={{cursor: 'pointer'}}
|
||||
/>
|
||||
)}
|
||||
<Toolbar>
|
||||
<Input.Search
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
value={query}
|
||||
placeholder="Search Flipper plugins..."
|
||||
/>
|
||||
</Toolbar>
|
||||
<ManagedTable
|
||||
rowLineHeight={28}
|
||||
floating={false}
|
||||
multiline
|
||||
columnSizes={columnSizes}
|
||||
columns={columns}
|
||||
highlightableRows={false}
|
||||
highlightedRows={new Set()}
|
||||
autoHeight={autoHeight}
|
||||
rows={rows}
|
||||
horizontallyScrollable
|
||||
/>
|
||||
<PluginPackageInstaller onInstall={onInstall} />
|
||||
</Layout.Container>
|
||||
);
|
||||
};
|
||||
|
||||
function InstallButton(props: {
|
||||
name: string;
|
||||
version: string;
|
||||
onInstall: () => void;
|
||||
updateStatus: UpdateResult;
|
||||
}) {
|
||||
type InstallAction =
|
||||
| {kind: 'Install'; error?: string}
|
||||
| {kind: 'Waiting'}
|
||||
| {kind: 'Remove'; error?: string}
|
||||
| {kind: 'Update'; error?: string};
|
||||
|
||||
const catchError =
|
||||
(actionKind: 'Install' | 'Remove' | 'Update', fn: () => Promise<void>) =>
|
||||
async () => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`Installation process of kind ${actionKind} failed with:`,
|
||||
err,
|
||||
);
|
||||
setAction({kind: actionKind, error: err.toString()});
|
||||
}
|
||||
};
|
||||
|
||||
const mkInstallCallback = (action: 'Install' | 'Update') =>
|
||||
catchError(action, async () => {
|
||||
reportUsage(
|
||||
action === 'Install' ? `${TAG}:install` : `${TAG}:update`,
|
||||
undefined,
|
||||
props.name,
|
||||
);
|
||||
setAction({kind: 'Waiting'});
|
||||
|
||||
await installPluginFromNpm(props.name);
|
||||
|
||||
props.onInstall();
|
||||
setAction({kind: 'Remove'});
|
||||
});
|
||||
|
||||
const performInstall = useCallback(mkInstallCallback('Install'), [
|
||||
props.name,
|
||||
props.version,
|
||||
]);
|
||||
|
||||
const performUpdate = useCallback(mkInstallCallback('Update'), [
|
||||
props.name,
|
||||
props.version,
|
||||
]);
|
||||
|
||||
const performRemove = useCallback(
|
||||
catchError('Remove', async () => {
|
||||
reportUsage(`${TAG}:remove`, undefined, props.name);
|
||||
setAction({kind: 'Waiting'});
|
||||
await removePlugin(props.name);
|
||||
props.onInstall();
|
||||
setAction({kind: 'Install'});
|
||||
}),
|
||||
[props.name],
|
||||
);
|
||||
|
||||
const [action, setAction] = useState<InstallAction>(
|
||||
props.updateStatus.kind === 'update-available'
|
||||
? {kind: 'Update'}
|
||||
: props.updateStatus.kind === 'not-installed'
|
||||
? {kind: 'Install'}
|
||||
: {kind: 'Remove'},
|
||||
);
|
||||
|
||||
if (action.kind === 'Waiting') {
|
||||
return <LoadingIndicator size={16} />;
|
||||
}
|
||||
if ((action.kind === 'Install' || action.kind === 'Remove') && action.error) {
|
||||
}
|
||||
const button = (
|
||||
<Button
|
||||
size="small"
|
||||
type={action.kind !== 'Remove' ? 'primary' : undefined}
|
||||
onClick={() => {
|
||||
switch (action.kind) {
|
||||
case 'Install':
|
||||
reportPlatformFailures(performInstall(), `${TAG}:install`);
|
||||
break;
|
||||
case 'Remove':
|
||||
reportPlatformFailures(performRemove(), `${TAG}:remove`);
|
||||
break;
|
||||
case 'Update':
|
||||
reportPlatformFailures(performUpdate(), `${TAG}:update`);
|
||||
break;
|
||||
}
|
||||
}}>
|
||||
{action.kind}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (action.error) {
|
||||
const glyph = (
|
||||
<Glyph color={theme.warningColor} size={16} name="caution-triangle" />
|
||||
);
|
||||
return (
|
||||
<Layout.Horizontal gap>
|
||||
<Tooltip
|
||||
placement="leftBottom"
|
||||
title={`Something went wrong: ${action.error}`}
|
||||
children={glyph}
|
||||
/>
|
||||
{button}
|
||||
</Layout.Horizontal>
|
||||
);
|
||||
} else {
|
||||
return button;
|
||||
}
|
||||
}
|
||||
|
||||
function useNPMSearch(
|
||||
query: string,
|
||||
onInstall: () => void,
|
||||
installedPlugins: Map<string, InstalledPluginDetails>,
|
||||
): TableRows {
|
||||
useEffect(() => {
|
||||
reportUsage(`${TAG}:open`);
|
||||
}, []);
|
||||
|
||||
const [searchResults, setSearchResults] = useState<UpdatablePluginDetails[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
const createRow = useCallback(
|
||||
(h: UpdatablePluginDetails) => ({
|
||||
key: h.name,
|
||||
columns: {
|
||||
name: {
|
||||
value: <Text ellipsis>{h.name.replace(/^flipper-plugin-/, '')}</Text>,
|
||||
},
|
||||
version: {
|
||||
value: <Text ellipsis>{h.version}</Text>,
|
||||
align: 'flex-end' as 'flex-end',
|
||||
},
|
||||
description: {
|
||||
value: (
|
||||
<Layout.Horizontal center gap>
|
||||
<Text ellipsis>{h.description}</Text>
|
||||
<Link href={`https://yarnpkg.com/en/package/${h.name}`}>
|
||||
<Glyph
|
||||
color={theme.textColorActive}
|
||||
name="info-circle"
|
||||
size={16}
|
||||
/>
|
||||
</Link>
|
||||
</Layout.Horizontal>
|
||||
),
|
||||
},
|
||||
install: {
|
||||
value: (
|
||||
<InstallButton
|
||||
name={h.name}
|
||||
version={h.version}
|
||||
onInstall={onInstall}
|
||||
updateStatus={h.updateStatus}
|
||||
/>
|
||||
),
|
||||
align: 'center' as 'center',
|
||||
},
|
||||
},
|
||||
}),
|
||||
[onInstall],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
let canceled = false;
|
||||
const updatablePlugins = await reportPlatformFailures(
|
||||
getUpdatablePlugins(query),
|
||||
`${TAG}:queryIndex`,
|
||||
);
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
setSearchResults(updatablePlugins);
|
||||
// Clean up: if query changes while we're searching, abandon results.
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
})();
|
||||
}, [query, installedPlugins]);
|
||||
|
||||
const rows = searchResults.map(createRow);
|
||||
return rows;
|
||||
}
|
||||
|
||||
PluginInstaller.defaultProps = defaultProps;
|
||||
|
||||
export default connect<PropsFromState, DispatchFromProps, OwnProps, AppState>(
|
||||
({plugins: {installedPlugins}}) => ({
|
||||
installedPlugins,
|
||||
}),
|
||||
(dispatch: Dispatch<Action<any>>) => ({
|
||||
refreshInstalledPlugins: async () => {
|
||||
const plugins = await getInstalledPlugins();
|
||||
dispatch(registerInstalledPlugins(plugins));
|
||||
},
|
||||
}),
|
||||
)(PluginInstaller);
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* 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 {Tab, Tabs} from 'flipper-plugin';
|
||||
import PluginDebugger from './PluginDebugger';
|
||||
import PluginInstaller from './PluginInstaller';
|
||||
import {Modal} from 'antd';
|
||||
|
||||
export default function (props: {onHide: () => any}) {
|
||||
return (
|
||||
<Modal width={800} visible onCancel={props.onHide} footer={null}>
|
||||
<Tabs>
|
||||
<Tab tab="Plugin Status">
|
||||
<PluginDebugger />
|
||||
</Tab>
|
||||
<Tab tab="Install Plugins">
|
||||
<PluginInstaller autoHeight />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
import {
|
||||
Button,
|
||||
FlexRow,
|
||||
Tooltip,
|
||||
Glyph,
|
||||
colors,
|
||||
LoadingIndicator,
|
||||
} from '../../ui';
|
||||
import styled from '@emotion/styled';
|
||||
import {default as FileSelector} from '../../ui/components/FileSelector';
|
||||
import React, {useState} from 'react';
|
||||
import {installPluginFromFile} from 'flipper-plugin-lib';
|
||||
import {Toolbar} from 'flipper-plugin';
|
||||
|
||||
const CenteredGlyph = styled(Glyph)({
|
||||
margin: 'auto',
|
||||
marginLeft: 2,
|
||||
});
|
||||
|
||||
const Spinner = styled(LoadingIndicator)({
|
||||
margin: 'auto',
|
||||
marginLeft: 16,
|
||||
});
|
||||
|
||||
const ButtonContainer = styled(FlexRow)({
|
||||
width: 76,
|
||||
});
|
||||
|
||||
const ErrorGlyphContainer = styled(FlexRow)({
|
||||
width: 20,
|
||||
});
|
||||
|
||||
export default function PluginPackageInstaller({
|
||||
onInstall,
|
||||
}: {
|
||||
onInstall: () => Promise<void>;
|
||||
}) {
|
||||
const [path, setPath] = useState('');
|
||||
const [isPathValid, setIsPathValid] = useState(false);
|
||||
const [error, setError] = useState<Error>();
|
||||
const [inProgress, setInProgress] = useState(false);
|
||||
const onClick = async () => {
|
||||
setError(undefined);
|
||||
setInProgress(true);
|
||||
try {
|
||||
await installPluginFromFile(path);
|
||||
await onInstall();
|
||||
} catch (e) {
|
||||
setError(e);
|
||||
console.error('PluginPackageInstaller install error:', e);
|
||||
} finally {
|
||||
setInProgress(false);
|
||||
}
|
||||
};
|
||||
const button = inProgress ? (
|
||||
<Spinner size={16} />
|
||||
) : (
|
||||
<Button
|
||||
compact
|
||||
type="primary"
|
||||
disabled={!isPathValid}
|
||||
title={
|
||||
isPathValid
|
||||
? 'Click to install the specified plugin package'
|
||||
: 'Cannot install plugin package by the specified path'
|
||||
}
|
||||
onClick={onClick}>
|
||||
Install
|
||||
</Button>
|
||||
);
|
||||
return (
|
||||
<Toolbar>
|
||||
<FileSelector
|
||||
placeholderText="Specify path to a Flipper package or just drag and drop it here..."
|
||||
onPathChanged={(e) => {
|
||||
setPath(e.path);
|
||||
setIsPathValid(e.isValid);
|
||||
setError(undefined);
|
||||
}}
|
||||
/>
|
||||
<ButtonContainer>
|
||||
<FlexRow>
|
||||
{button}
|
||||
<ErrorGlyphContainer>
|
||||
{error && (
|
||||
<Tooltip
|
||||
options={{position: 'toRight'}}
|
||||
title={`Something went wrong: ${error}`}>
|
||||
<CenteredGlyph
|
||||
color={colors.orange}
|
||||
size={16}
|
||||
name="caution-triangle"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ErrorGlyphContainer>
|
||||
</FlexRow>
|
||||
</ButtonContainer>
|
||||
</Toolbar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*
|
||||
* @format
|
||||
*/
|
||||
|
||||
jest.mock('flipper-plugin-lib');
|
||||
|
||||
import {default as PluginInstaller} from '../PluginInstaller';
|
||||
import React from 'react';
|
||||
import {render, waitFor} from '@testing-library/react';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import {Provider} from 'react-redux';
|
||||
import type {PluginDetails} from 'flipper-plugin-lib';
|
||||
import {getUpdatablePlugins, UpdatablePluginDetails} from 'flipper-plugin-lib';
|
||||
import {Store} from '../../../reducers';
|
||||
import {mocked} from 'ts-jest/utils';
|
||||
|
||||
const getUpdatablePluginsMock = mocked(getUpdatablePlugins);
|
||||
|
||||
function getStore(installedPlugins: PluginDetails[] = []): Store {
|
||||
return configureStore([])({
|
||||
application: {sessionId: 'mysession'},
|
||||
plugins: {installedPlugins},
|
||||
}) as Store;
|
||||
}
|
||||
|
||||
const samplePluginDetails1: UpdatablePluginDetails = {
|
||||
name: 'flipper-plugin-hello',
|
||||
entry: './test/index.js',
|
||||
version: '0.1.0',
|
||||
specVersion: 2,
|
||||
pluginType: 'client',
|
||||
main: 'dist/bundle.js',
|
||||
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample1',
|
||||
source: 'src/index.js',
|
||||
id: 'Hello',
|
||||
title: 'Hello',
|
||||
description: 'World?',
|
||||
isBundled: false,
|
||||
isActivatable: true,
|
||||
updateStatus: {
|
||||
kind: 'not-installed',
|
||||
version: '0.1.0',
|
||||
},
|
||||
};
|
||||
|
||||
const samplePluginDetails2: UpdatablePluginDetails = {
|
||||
name: 'flipper-plugin-world',
|
||||
entry: './test/index.js',
|
||||
version: '0.2.0',
|
||||
specVersion: 2,
|
||||
pluginType: 'client',
|
||||
main: 'dist/bundle.js',
|
||||
dir: '/Users/mock/.flipper/thirdparty/flipper-plugin-sample2',
|
||||
source: 'src/index.js',
|
||||
id: 'World',
|
||||
title: 'World',
|
||||
description: 'Hello?',
|
||||
isBundled: false,
|
||||
isActivatable: true,
|
||||
updateStatus: {
|
||||
kind: 'not-installed',
|
||||
version: '0.2.0',
|
||||
},
|
||||
};
|
||||
|
||||
const SEARCH_RESULTS = [samplePluginDetails1, samplePluginDetails2];
|
||||
|
||||
afterEach(() => {
|
||||
getUpdatablePluginsMock.mockClear();
|
||||
});
|
||||
|
||||
test('load PluginInstaller list', async () => {
|
||||
getUpdatablePluginsMock.mockReturnValue(Promise.resolve(SEARCH_RESULTS));
|
||||
const component = (
|
||||
<Provider store={getStore()}>
|
||||
<PluginInstaller
|
||||
// Bit ugly to have this as an effectively test-only option, but
|
||||
// without, we rely on height information from Electron which we don't
|
||||
// have, causing no items to be rendered.
|
||||
autoHeight
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
const {container, getByText} = render(component);
|
||||
await waitFor(() => getByText('hello'));
|
||||
expect(getUpdatablePluginsMock.mock.calls.length).toBe(1);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test('load PluginInstaller list with one plugin installed', async () => {
|
||||
getUpdatablePluginsMock.mockReturnValue(
|
||||
Promise.resolve([
|
||||
{...samplePluginDetails1, updateStatus: {kind: 'up-to-date'}},
|
||||
samplePluginDetails2,
|
||||
]),
|
||||
);
|
||||
const store = getStore([samplePluginDetails1]);
|
||||
const component = (
|
||||
<Provider store={store}>
|
||||
<PluginInstaller
|
||||
// Bit ugly to have this as an effectively test-only option, but
|
||||
// without, we rely on height information from Electron which we don't
|
||||
// have, causing no items to be rendered.
|
||||
autoHeight
|
||||
/>
|
||||
</Provider>
|
||||
);
|
||||
const {container, getByText} = render(component);
|
||||
await waitFor(() => getByText('hello'));
|
||||
expect(getUpdatablePluginsMock.mock.calls.length).toBe(1);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
@@ -0,0 +1,669 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`load PluginInstaller list 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="css-1v0y38i-Container e1hsqii15"
|
||||
height="500"
|
||||
>
|
||||
<div
|
||||
class="css-1lxv8hi-Container-Horizontal-SandyToolbarContainer e1ecpah20"
|
||||
>
|
||||
<span
|
||||
class="ant-input-group-wrapper ant-input-search"
|
||||
>
|
||||
<span
|
||||
class="ant-input-wrapper ant-input-group"
|
||||
>
|
||||
<input
|
||||
class="ant-input"
|
||||
placeholder="Search Flipper plugins..."
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<span
|
||||
class="ant-input-group-addon"
|
||||
>
|
||||
<button
|
||||
class="ant-btn ant-btn-icon-only ant-input-search-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-label="search"
|
||||
class="anticon anticon-search"
|
||||
role="img"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-icon="search"
|
||||
fill="currentColor"
|
||||
focusable="false"
|
||||
height="1em"
|
||||
viewBox="64 64 896 896"
|
||||
width="1em"
|
||||
>
|
||||
<path
|
||||
d="M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0011.6 0l43.6-43.5a8.2 8.2 0 000-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-bgfc37-View-FlexBox-FlexColumn-Container emab7y20"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="ant-dropdown-trigger css-18abd42-View-FlexBox-FlexColumn e1e47qlf0"
|
||||
>
|
||||
<div
|
||||
class="css-1otvu18-View-FlexBox-FlexRow-TableHeadContainer eig1lcc1"
|
||||
>
|
||||
<div
|
||||
class="css-mpoiz3-TableHeadColumnContainer eig1lcc0"
|
||||
title="name"
|
||||
width="0"
|
||||
>
|
||||
<div
|
||||
class="eig1lcc3 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
|
||||
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-1054ash-TableHeaderColumnContainer eig1lcc2"
|
||||
>
|
||||
Name
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-mpoiz3-TableHeadColumnContainer eig1lcc0"
|
||||
title="version"
|
||||
width="0"
|
||||
>
|
||||
<div
|
||||
class="eig1lcc3 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
|
||||
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-1054ash-TableHeaderColumnContainer eig1lcc2"
|
||||
>
|
||||
Version
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-mpoiz3-TableHeadColumnContainer eig1lcc0"
|
||||
title="description"
|
||||
width="0"
|
||||
>
|
||||
<div
|
||||
class="eig1lcc3 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
|
||||
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-1054ash-TableHeaderColumnContainer eig1lcc2"
|
||||
>
|
||||
Description
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-mpoiz3-TableHeadColumnContainer eig1lcc0"
|
||||
title="install"
|
||||
width="0"
|
||||
>
|
||||
<div
|
||||
class="eig1lcc3 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
|
||||
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-1054ash-TableHeaderColumnContainer eig1lcc2"
|
||||
>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-p5h61d-View-FlexBox-FlexColumn-Container emab7y20"
|
||||
>
|
||||
<div
|
||||
class="ant-dropdown-trigger css-18abd42-View-FlexBox-FlexColumn e1e47qlf0"
|
||||
>
|
||||
<div
|
||||
class="css-hg3ptm-View-FlexBox-FlexRow-TableBodyRowContainer e1pvjj0s1"
|
||||
data-key="flipper-plugin-hello"
|
||||
>
|
||||
<div
|
||||
class="css-yt4330-TableBodyColumnContainer e1pvjj0s0"
|
||||
title=""
|
||||
width="0"
|
||||
>
|
||||
<span
|
||||
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
|
||||
>
|
||||
hello
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-pfp0fy-TableBodyColumnContainer e1pvjj0s0"
|
||||
title=""
|
||||
width="0"
|
||||
>
|
||||
<span
|
||||
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
|
||||
>
|
||||
0.1.0
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-yt4330-TableBodyColumnContainer e1pvjj0s0"
|
||||
title=""
|
||||
width="0"
|
||||
>
|
||||
<div
|
||||
class="css-s1wsbn-Container-Horizontal e1hsqii14"
|
||||
>
|
||||
<span
|
||||
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
|
||||
>
|
||||
World?
|
||||
</span>
|
||||
<a
|
||||
class="ant-typography"
|
||||
href="https://yarnpkg.com/en/package/flipper-plugin-hello"
|
||||
>
|
||||
<div
|
||||
class="css-1kmzf9v-ColoredIconCustom ekc8qeh0"
|
||||
color="var(--light-color-button-active)"
|
||||
size="16"
|
||||
src="https://facebook.com/assets/?name=info-circle&variant=filled&size=16&set=facebook_icons&density=1x"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-16v1lq1-TableBodyColumnContainer e1pvjj0s0"
|
||||
title=""
|
||||
width="0"
|
||||
>
|
||||
<button
|
||||
class="ant-btn ant-btn-primary ant-btn-sm"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Install
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-hg3ptm-View-FlexBox-FlexRow-TableBodyRowContainer e1pvjj0s1"
|
||||
data-key="flipper-plugin-world"
|
||||
>
|
||||
<div
|
||||
class="css-yt4330-TableBodyColumnContainer e1pvjj0s0"
|
||||
title=""
|
||||
width="0"
|
||||
>
|
||||
<span
|
||||
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
|
||||
>
|
||||
world
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-pfp0fy-TableBodyColumnContainer e1pvjj0s0"
|
||||
title=""
|
||||
width="0"
|
||||
>
|
||||
<span
|
||||
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
|
||||
>
|
||||
0.2.0
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-yt4330-TableBodyColumnContainer e1pvjj0s0"
|
||||
title=""
|
||||
width="0"
|
||||
>
|
||||
<div
|
||||
class="css-s1wsbn-Container-Horizontal e1hsqii14"
|
||||
>
|
||||
<span
|
||||
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
|
||||
>
|
||||
Hello?
|
||||
</span>
|
||||
<a
|
||||
class="ant-typography"
|
||||
href="https://yarnpkg.com/en/package/flipper-plugin-world"
|
||||
>
|
||||
<div
|
||||
class="css-1kmzf9v-ColoredIconCustom ekc8qeh0"
|
||||
color="var(--light-color-button-active)"
|
||||
size="16"
|
||||
src="https://facebook.com/assets/?name=info-circle&variant=filled&size=16&set=facebook_icons&density=1x"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-16v1lq1-TableBodyColumnContainer e1pvjj0s0"
|
||||
title=""
|
||||
width="0"
|
||||
>
|
||||
<button
|
||||
class="ant-btn ant-btn-primary ant-btn-sm"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Install
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-1lxv8hi-Container-Horizontal-SandyToolbarContainer e1ecpah20"
|
||||
>
|
||||
<div
|
||||
class="css-1spj5hr-View-FlexBox-FlexRow-Container ev83mp62"
|
||||
>
|
||||
<input
|
||||
class="css-sli06x-Input-FileInputBox ev83mp60"
|
||||
placeholder="Specify path to a Flipper package or just drag and drop it here..."
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
class="css-ccdckn-View-FlexBox-FlexRow-GlyphContainer ev83mp61"
|
||||
>
|
||||
<img
|
||||
alt="dots-3-circle"
|
||||
class="ev83mp63 css-6iptsk-ColoredIconBlack-CenteredGlyph ekc8qeh1"
|
||||
size="16"
|
||||
src="https://facebook.com/assets/?name=dots-3-circle&variant=outline&size=16&set=facebook_icons&density=1x"
|
||||
title="Open file selection dialog"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="css-ccdckn-View-FlexBox-FlexRow-GlyphContainer ev83mp61"
|
||||
>
|
||||
<div
|
||||
class="css-auhar3-TooltipContainer e1m67rki0"
|
||||
>
|
||||
<div
|
||||
class="ev83mp63 css-1qsl9s4-ColoredIconCustom-CenteredGlyph ekc8qeh0"
|
||||
color="#D79651"
|
||||
size="16"
|
||||
src="https://facebook.com/assets/?name=caution-triangle&variant=filled&size=16&set=facebook_icons&density=1x"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-5ukfaz-View-FlexBox-FlexRow-ButtonContainer eguixfz1"
|
||||
>
|
||||
<div
|
||||
class="css-wospjg-View-FlexBox-FlexRow ek54xq0"
|
||||
>
|
||||
<button
|
||||
class="ant-btn ant-btn-primary"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Install
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
class="css-170i4ha-View-FlexBox-FlexRow-ErrorGlyphContainer eguixfz0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`load PluginInstaller list with one plugin installed 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="css-1v0y38i-Container e1hsqii15"
|
||||
height="500"
|
||||
>
|
||||
<div
|
||||
class="css-1lxv8hi-Container-Horizontal-SandyToolbarContainer e1ecpah20"
|
||||
>
|
||||
<span
|
||||
class="ant-input-group-wrapper ant-input-search"
|
||||
>
|
||||
<span
|
||||
class="ant-input-wrapper ant-input-group"
|
||||
>
|
||||
<input
|
||||
class="ant-input"
|
||||
placeholder="Search Flipper plugins..."
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<span
|
||||
class="ant-input-group-addon"
|
||||
>
|
||||
<button
|
||||
class="ant-btn ant-btn-icon-only ant-input-search-button"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
aria-label="search"
|
||||
class="anticon anticon-search"
|
||||
role="img"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
data-icon="search"
|
||||
fill="currentColor"
|
||||
focusable="false"
|
||||
height="1em"
|
||||
viewBox="64 64 896 896"
|
||||
width="1em"
|
||||
>
|
||||
<path
|
||||
d="M909.6 854.5L649.9 594.8C690.2 542.7 712 479 712 412c0-80.2-31.3-155.4-87.9-212.1-56.6-56.7-132-87.9-212.1-87.9s-155.5 31.3-212.1 87.9C143.2 256.5 112 331.8 112 412c0 80.1 31.3 155.5 87.9 212.1C256.5 680.8 331.8 712 412 712c67 0 130.6-21.8 182.7-62l259.7 259.6a8.2 8.2 0 0011.6 0l43.6-43.5a8.2 8.2 0 000-11.6zM570.4 570.4C528 612.7 471.8 636 412 636s-116-23.3-158.4-65.6C211.3 528 188 471.8 188 412s23.3-116.1 65.6-158.4C296 211.3 352.2 188 412 188s116.1 23.2 158.4 65.6S636 352.2 636 412s-23.3 116.1-65.6 158.4z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-bgfc37-View-FlexBox-FlexColumn-Container emab7y20"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="ant-dropdown-trigger css-18abd42-View-FlexBox-FlexColumn e1e47qlf0"
|
||||
>
|
||||
<div
|
||||
class="css-1otvu18-View-FlexBox-FlexRow-TableHeadContainer eig1lcc1"
|
||||
>
|
||||
<div
|
||||
class="css-mpoiz3-TableHeadColumnContainer eig1lcc0"
|
||||
title="name"
|
||||
width="0"
|
||||
>
|
||||
<div
|
||||
class="eig1lcc3 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
|
||||
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-1054ash-TableHeaderColumnContainer eig1lcc2"
|
||||
>
|
||||
Name
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-mpoiz3-TableHeadColumnContainer eig1lcc0"
|
||||
title="version"
|
||||
width="0"
|
||||
>
|
||||
<div
|
||||
class="eig1lcc3 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
|
||||
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-1054ash-TableHeaderColumnContainer eig1lcc2"
|
||||
>
|
||||
Version
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-mpoiz3-TableHeadColumnContainer eig1lcc0"
|
||||
title="description"
|
||||
width="0"
|
||||
>
|
||||
<div
|
||||
class="eig1lcc3 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
|
||||
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-1054ash-TableHeaderColumnContainer eig1lcc2"
|
||||
>
|
||||
Description
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-mpoiz3-TableHeadColumnContainer eig1lcc0"
|
||||
title="install"
|
||||
width="0"
|
||||
>
|
||||
<div
|
||||
class="eig1lcc3 css-x4q70f-InteractiveContainer-TableHeaderColumnInteractive e14xwmxq0"
|
||||
style="z-index: auto; right: 0px; bottom: 0px; width: 100%; height: 100%;"
|
||||
>
|
||||
<div
|
||||
class="css-1054ash-TableHeaderColumnContainer eig1lcc2"
|
||||
>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-p5h61d-View-FlexBox-FlexColumn-Container emab7y20"
|
||||
>
|
||||
<div
|
||||
class="ant-dropdown-trigger css-18abd42-View-FlexBox-FlexColumn e1e47qlf0"
|
||||
>
|
||||
<div
|
||||
class="css-hg3ptm-View-FlexBox-FlexRow-TableBodyRowContainer e1pvjj0s1"
|
||||
data-key="flipper-plugin-hello"
|
||||
>
|
||||
<div
|
||||
class="css-yt4330-TableBodyColumnContainer e1pvjj0s0"
|
||||
title=""
|
||||
width="0"
|
||||
>
|
||||
<span
|
||||
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
|
||||
>
|
||||
hello
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-pfp0fy-TableBodyColumnContainer e1pvjj0s0"
|
||||
title=""
|
||||
width="0"
|
||||
>
|
||||
<span
|
||||
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
|
||||
>
|
||||
0.1.0
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-yt4330-TableBodyColumnContainer e1pvjj0s0"
|
||||
title=""
|
||||
width="0"
|
||||
>
|
||||
<div
|
||||
class="css-s1wsbn-Container-Horizontal e1hsqii14"
|
||||
>
|
||||
<span
|
||||
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
|
||||
>
|
||||
World?
|
||||
</span>
|
||||
<a
|
||||
class="ant-typography"
|
||||
href="https://yarnpkg.com/en/package/flipper-plugin-hello"
|
||||
>
|
||||
<div
|
||||
class="css-1kmzf9v-ColoredIconCustom ekc8qeh0"
|
||||
color="var(--light-color-button-active)"
|
||||
size="16"
|
||||
src="https://facebook.com/assets/?name=info-circle&variant=filled&size=16&set=facebook_icons&density=1x"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-16v1lq1-TableBodyColumnContainer e1pvjj0s0"
|
||||
title=""
|
||||
width="0"
|
||||
>
|
||||
<button
|
||||
class="ant-btn ant-btn-sm"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Remove
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-hg3ptm-View-FlexBox-FlexRow-TableBodyRowContainer e1pvjj0s1"
|
||||
data-key="flipper-plugin-world"
|
||||
>
|
||||
<div
|
||||
class="css-yt4330-TableBodyColumnContainer e1pvjj0s0"
|
||||
title=""
|
||||
width="0"
|
||||
>
|
||||
<span
|
||||
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
|
||||
>
|
||||
world
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-pfp0fy-TableBodyColumnContainer e1pvjj0s0"
|
||||
title=""
|
||||
width="0"
|
||||
>
|
||||
<span
|
||||
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
|
||||
>
|
||||
0.2.0
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="css-yt4330-TableBodyColumnContainer e1pvjj0s0"
|
||||
title=""
|
||||
width="0"
|
||||
>
|
||||
<div
|
||||
class="css-s1wsbn-Container-Horizontal e1hsqii14"
|
||||
>
|
||||
<span
|
||||
class="ant-typography ant-typography-ellipsis ant-typography-single-line ant-typography-ellipsis-single-line"
|
||||
>
|
||||
Hello?
|
||||
</span>
|
||||
<a
|
||||
class="ant-typography"
|
||||
href="https://yarnpkg.com/en/package/flipper-plugin-world"
|
||||
>
|
||||
<div
|
||||
class="css-1kmzf9v-ColoredIconCustom ekc8qeh0"
|
||||
color="var(--light-color-button-active)"
|
||||
size="16"
|
||||
src="https://facebook.com/assets/?name=info-circle&variant=filled&size=16&set=facebook_icons&density=1x"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-16v1lq1-TableBodyColumnContainer e1pvjj0s0"
|
||||
title=""
|
||||
width="0"
|
||||
>
|
||||
<button
|
||||
class="ant-btn ant-btn-primary ant-btn-sm"
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Install
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-1lxv8hi-Container-Horizontal-SandyToolbarContainer e1ecpah20"
|
||||
>
|
||||
<div
|
||||
class="css-1spj5hr-View-FlexBox-FlexRow-Container ev83mp62"
|
||||
>
|
||||
<input
|
||||
class="css-sli06x-Input-FileInputBox ev83mp60"
|
||||
placeholder="Specify path to a Flipper package or just drag and drop it here..."
|
||||
type="text"
|
||||
value=""
|
||||
/>
|
||||
<div
|
||||
class="css-ccdckn-View-FlexBox-FlexRow-GlyphContainer ev83mp61"
|
||||
>
|
||||
<img
|
||||
alt="dots-3-circle"
|
||||
class="ev83mp63 css-6iptsk-ColoredIconBlack-CenteredGlyph ekc8qeh1"
|
||||
size="16"
|
||||
src="https://facebook.com/assets/?name=dots-3-circle&variant=outline&size=16&set=facebook_icons&density=1x"
|
||||
title="Open file selection dialog"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="css-ccdckn-View-FlexBox-FlexRow-GlyphContainer ev83mp61"
|
||||
>
|
||||
<div
|
||||
class="css-auhar3-TooltipContainer e1m67rki0"
|
||||
>
|
||||
<div
|
||||
class="ev83mp63 css-1qsl9s4-ColoredIconCustom-CenteredGlyph ekc8qeh0"
|
||||
color="#D79651"
|
||||
size="16"
|
||||
src="https://facebook.com/assets/?name=caution-triangle&variant=filled&size=16&set=facebook_icons&density=1x"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="css-5ukfaz-View-FlexBox-FlexRow-ButtonContainer eguixfz1"
|
||||
>
|
||||
<div
|
||||
class="css-wospjg-View-FlexBox-FlexRow ek54xq0"
|
||||
>
|
||||
<button
|
||||
class="ant-btn ant-btn-primary"
|
||||
disabled=""
|
||||
type="button"
|
||||
>
|
||||
<span>
|
||||
Install
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
class="css-170i4ha-View-FlexBox-FlexRow-ErrorGlyphContainer eguixfz0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* 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, styled, FlexRow, Text, Glyph, colors} from '../../ui';
|
||||
import React, {useRef, useState, useEffect} from 'react';
|
||||
import {theme} from 'flipper-plugin';
|
||||
|
||||
type PressedKeys = {
|
||||
metaKey: boolean;
|
||||
altKey: boolean;
|
||||
ctrlKey: boolean;
|
||||
shiftKey: boolean;
|
||||
character: string;
|
||||
};
|
||||
|
||||
const KEYCODES = {
|
||||
DELETE: 8,
|
||||
ALT: 18,
|
||||
SHIFT: 16,
|
||||
CTRL: 17,
|
||||
LEFT_COMMAND: 91, // Left ⌘ / Windows Key / Chromebook Search key
|
||||
RIGHT_COMMAND: 93, // Right ⌘ / Windows Menu
|
||||
};
|
||||
|
||||
const ACCELERATORS = {
|
||||
COMMAND: 'Command',
|
||||
ALT: 'Alt',
|
||||
CONTROL: 'Control',
|
||||
SHIFT: 'Shift',
|
||||
};
|
||||
|
||||
const Container = styled(FlexRow)({
|
||||
paddingTop: 5,
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
});
|
||||
|
||||
const Label = styled(Text)({
|
||||
flex: 1,
|
||||
alignSelf: 'center',
|
||||
});
|
||||
|
||||
const ShortcutKeysContainer = styled(FlexRow)<{invalid: boolean}>(
|
||||
{
|
||||
flex: 1,
|
||||
backgroundColor: theme.backgroundDefault,
|
||||
border: '1px solid',
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
height: 28,
|
||||
padding: 2,
|
||||
},
|
||||
(props) => ({
|
||||
borderColor: props.invalid ? theme.errorColor : theme.dividerColor,
|
||||
}),
|
||||
);
|
||||
|
||||
const ShortcutKeyContainer = styled.div({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
border: `1px solid ${theme.dividerColor}`,
|
||||
backgroundColor: theme.backgroundWash,
|
||||
padding: 3,
|
||||
margin: '0 1px',
|
||||
borderRadius: 3,
|
||||
width: 23,
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
const ShortcutKey = styled.span({
|
||||
color: theme.textColorPrimary,
|
||||
});
|
||||
|
||||
const HiddenInput = styled.input({
|
||||
opacity: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
position: 'absolute',
|
||||
});
|
||||
|
||||
const CenteredGlyph = styled(Glyph)({
|
||||
margin: 'auto',
|
||||
marginLeft: 10,
|
||||
});
|
||||
|
||||
const KeyboardShortcutInput = (props: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
}) => {
|
||||
const getInitialStateFromProps = (): PressedKeys => ({
|
||||
metaKey: Boolean(props.value && props.value.includes(ACCELERATORS.COMMAND)),
|
||||
altKey: Boolean(props.value && props.value.includes(ACCELERATORS.ALT)),
|
||||
ctrlKey: Boolean(props.value && props.value.includes(ACCELERATORS.CONTROL)),
|
||||
shiftKey: Boolean(props.value && props.value.includes(ACCELERATORS.SHIFT)),
|
||||
character:
|
||||
props.value &&
|
||||
props.value.replace(
|
||||
new RegExp(
|
||||
`${ACCELERATORS.COMMAND}|${ACCELERATORS.ALT}|Or|${ACCELERATORS.CONTROL}|${ACCELERATORS.SHIFT}|\\+`,
|
||||
'g',
|
||||
),
|
||||
'',
|
||||
),
|
||||
});
|
||||
|
||||
const [initialPressedKeys] = useState<PressedKeys>(
|
||||
getInitialStateFromProps(),
|
||||
);
|
||||
const [pressedKeys, setPressedKeys] =
|
||||
useState<PressedKeys>(initialPressedKeys);
|
||||
const [isShortcutValid, setIsShortcutValid] = useState<boolean | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isShortcutValid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {metaKey, altKey, ctrlKey, shiftKey, character} = pressedKeys;
|
||||
|
||||
const accelerator = [
|
||||
metaKey && ACCELERATORS.COMMAND,
|
||||
altKey && ACCELERATORS.ALT,
|
||||
ctrlKey && ACCELERATORS.CONTROL,
|
||||
shiftKey && ACCELERATORS.SHIFT,
|
||||
character,
|
||||
].filter(Boolean);
|
||||
|
||||
if (typeof props.onChange === 'function') {
|
||||
props.onChange(accelerator.join('+'));
|
||||
}
|
||||
}, [isShortcutValid, pressedKeys, props]);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
let typingTimeout: NodeJS.Timeout;
|
||||
|
||||
const handleFocusInput = () => {
|
||||
if (inputRef.current !== null) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const isCharacterSpecial = (keycode: number) =>
|
||||
Object.values(KEYCODES).includes(keycode);
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.which === 9) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
const {metaKey, altKey, ctrlKey, shiftKey} = event;
|
||||
const character = isCharacterSpecial(event.which)
|
||||
? ''
|
||||
: String.fromCharCode(event.which);
|
||||
|
||||
setPressedKeys({
|
||||
metaKey,
|
||||
altKey,
|
||||
ctrlKey,
|
||||
shiftKey,
|
||||
character,
|
||||
});
|
||||
setIsShortcutValid(undefined);
|
||||
};
|
||||
|
||||
const handleKeyUp = () => {
|
||||
const {metaKey, altKey, ctrlKey, shiftKey, character} = pressedKeys;
|
||||
|
||||
clearTimeout(typingTimeout);
|
||||
typingTimeout = setTimeout(
|
||||
() =>
|
||||
setIsShortcutValid(
|
||||
([metaKey, altKey, ctrlKey, shiftKey].includes(true) &&
|
||||
character !== '') ||
|
||||
[metaKey, altKey, ctrlKey, shiftKey, character].every(
|
||||
(value) => !value,
|
||||
),
|
||||
),
|
||||
500,
|
||||
);
|
||||
};
|
||||
|
||||
const handleUpdatePressedKeys = (keys: PressedKeys) => {
|
||||
setPressedKeys(keys);
|
||||
handleKeyUp();
|
||||
handleFocusInput();
|
||||
setIsShortcutValid(undefined);
|
||||
};
|
||||
|
||||
const renderKeys = () => {
|
||||
const keys = [
|
||||
pressedKeys.metaKey && '⌘',
|
||||
pressedKeys.altKey && '⌥',
|
||||
pressedKeys.ctrlKey && '⌃',
|
||||
pressedKeys.shiftKey && '⇧',
|
||||
pressedKeys.character,
|
||||
].filter(Boolean);
|
||||
|
||||
return keys.map((key, index) => (
|
||||
<ShortcutKeyContainer key={index}>
|
||||
<ShortcutKey>{key}</ShortcutKey>
|
||||
</ShortcutKeyContainer>
|
||||
));
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Label>{props.label}</Label>
|
||||
<ShortcutKeysContainer
|
||||
invalid={isShortcutValid === false}
|
||||
onClick={handleFocusInput}>
|
||||
{renderKeys()}
|
||||
|
||||
<HiddenInput
|
||||
ref={inputRef}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
/>
|
||||
</ShortcutKeysContainer>
|
||||
|
||||
<FlexRow>
|
||||
<FlexColumn onClick={() => handleUpdatePressedKeys(initialPressedKeys)}>
|
||||
<CenteredGlyph
|
||||
color={theme.primaryColor}
|
||||
name="undo"
|
||||
variant="outline"
|
||||
/>
|
||||
</FlexColumn>
|
||||
|
||||
<FlexColumn
|
||||
onClick={() =>
|
||||
handleUpdatePressedKeys({
|
||||
metaKey: false,
|
||||
altKey: false,
|
||||
ctrlKey: false,
|
||||
shiftKey: false,
|
||||
character: '',
|
||||
})
|
||||
}>
|
||||
<CenteredGlyph
|
||||
color={theme.errorColor}
|
||||
name="cross"
|
||||
variant="outline"
|
||||
/>
|
||||
</FlexColumn>
|
||||
</FlexRow>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyboardShortcutInput;
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* 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, styled, FlexRow, ToggleButton} from '../../ui';
|
||||
import React from 'react';
|
||||
import {theme} from 'flipper-plugin';
|
||||
|
||||
const IndentedSection = styled(FlexColumn)({
|
||||
paddingLeft: 50,
|
||||
paddingBottom: 10,
|
||||
});
|
||||
const GrayedOutOverlay = styled.div({
|
||||
background: theme.backgroundDefault,
|
||||
borderRadius: 4,
|
||||
opacity: 0.6,
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
});
|
||||
|
||||
export default function ToggledSection(props: {
|
||||
label: string;
|
||||
toggled: boolean;
|
||||
onChange?: (value: boolean) => void;
|
||||
children?: React.ReactNode;
|
||||
// Whether to disallow interactions with this toggle
|
||||
frozen?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<FlexColumn>
|
||||
<FlexRow>
|
||||
<ToggleButton
|
||||
label={props.label}
|
||||
onClick={() => props.onChange && props.onChange(!props.toggled)}
|
||||
toggled={props.toggled}
|
||||
/>
|
||||
{props.frozen && <GrayedOutOverlay />}
|
||||
</FlexRow>
|
||||
<IndentedSection>
|
||||
{props.children}
|
||||
{props.toggled || props.frozen ? null : <GrayedOutOverlay />}
|
||||
</IndentedSection>
|
||||
</FlexColumn>
|
||||
);
|
||||
}
|
||||
153
desktop/flipper-ui-core/src/chrome/settings/configFields.tsx
Normal file
153
desktop/flipper-ui-core/src/chrome/settings/configFields.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* 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,
|
||||
styled,
|
||||
Text,
|
||||
FlexRow,
|
||||
Input,
|
||||
colors,
|
||||
Glyph,
|
||||
} from '../../ui';
|
||||
import React, {useState} from 'react';
|
||||
import {promises as fs} from 'fs';
|
||||
import {theme} from 'flipper-plugin';
|
||||
import {getRenderHostInstance} from '../../RenderHost';
|
||||
|
||||
export const ConfigFieldContainer = styled(FlexRow)({
|
||||
paddingLeft: 10,
|
||||
paddingRight: 10,
|
||||
marginBottom: 5,
|
||||
paddingTop: 5,
|
||||
});
|
||||
|
||||
export const InfoText = styled(Text)({
|
||||
lineHeight: 1.35,
|
||||
paddingTop: 5,
|
||||
});
|
||||
|
||||
const FileInputBox = styled(Input)<{isValid: boolean}>(({isValid}) => ({
|
||||
marginRight: 0,
|
||||
flexGrow: 1,
|
||||
fontFamily: 'monospace',
|
||||
color: isValid ? undefined : colors.red,
|
||||
marginLeft: 10,
|
||||
marginTop: 'auto',
|
||||
marginBottom: 'auto',
|
||||
}));
|
||||
|
||||
const CenteredGlyph = styled(Glyph)({
|
||||
margin: 'auto',
|
||||
marginLeft: 10,
|
||||
});
|
||||
|
||||
const GrayedOutOverlay = styled.div({
|
||||
backgroundColor: '#EFEEEF',
|
||||
borderRadius: 4,
|
||||
opacity: 0.6,
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
});
|
||||
|
||||
export function FilePathConfigField(props: {
|
||||
label: string;
|
||||
resetValue?: string;
|
||||
defaultValue: string;
|
||||
onChange: (path: string) => void;
|
||||
frozen?: boolean;
|
||||
// Defaults to allowing directories only, this changes to expect regular files.
|
||||
isRegularFile?: boolean;
|
||||
}) {
|
||||
const renderHost = getRenderHostInstance();
|
||||
const [value, setValue] = useState(props.defaultValue);
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
fs.stat(value)
|
||||
.then((stat) => props.isRegularFile !== stat.isDirectory())
|
||||
.then((valid) => {
|
||||
if (valid !== isValid) {
|
||||
setIsValid(valid);
|
||||
}
|
||||
})
|
||||
.catch((_) => setIsValid(false));
|
||||
|
||||
return (
|
||||
<ConfigFieldContainer>
|
||||
<InfoText>{props.label}</InfoText>
|
||||
<FileInputBox
|
||||
placeholder={props.label}
|
||||
value={value}
|
||||
isValid={isValid}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
props.onChange(e.target.value);
|
||||
fs.stat(e.target.value)
|
||||
.then((stat) => stat.isDirectory())
|
||||
.then((valid) => {
|
||||
if (valid !== isValid) {
|
||||
setIsValid(valid);
|
||||
}
|
||||
})
|
||||
.catch((_) => setIsValid(false));
|
||||
}}
|
||||
/>
|
||||
{renderHost.showSelectDirectoryDialog && (
|
||||
<FlexColumn
|
||||
onClick={() => {
|
||||
renderHost
|
||||
.showSelectDirectoryDialog?.()
|
||||
.then((path) => {
|
||||
if (path) {
|
||||
setValue(path);
|
||||
props.onChange(path);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn('Failed to select dir', e);
|
||||
});
|
||||
}}>
|
||||
<CenteredGlyph
|
||||
color={theme.primaryColor}
|
||||
name="dots-3-circle"
|
||||
variant="outline"
|
||||
/>
|
||||
</FlexColumn>
|
||||
)}
|
||||
{props.resetValue && (
|
||||
<FlexColumn
|
||||
title={`Reset to default path ${props.resetValue}`}
|
||||
onClick={() => {
|
||||
setValue(props.resetValue!);
|
||||
props.onChange(props.resetValue!);
|
||||
}}>
|
||||
<CenteredGlyph
|
||||
color={theme.primaryColor}
|
||||
name="undo"
|
||||
variant="outline"
|
||||
/>
|
||||
</FlexColumn>
|
||||
)}
|
||||
{isValid ? null : (
|
||||
<CenteredGlyph name="caution-triangle" color={colors.yellow} />
|
||||
)}
|
||||
{props.frozen && <GrayedOutOverlay />}
|
||||
</ConfigFieldContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConfigText(props: {content: string; frozen?: boolean}) {
|
||||
return (
|
||||
<ConfigFieldContainer>
|
||||
<InfoText>{props.content}</InfoText>
|
||||
{props.frozen && <GrayedOutOverlay />}
|
||||
</ConfigFieldContainer>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user