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:
Michel Weststrate
2021-11-16 05:25:40 -08:00
committed by Facebook GitHub Bot
parent 54b7ce9308
commit 7e50c0466a
293 changed files with 483 additions and 497 deletions

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

View 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,
};
}

View 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);

View 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);

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

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

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

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

View 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');
}}
/>
</>
);
}

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

View 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');
}

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

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

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

View 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}
/>
</>
);
}

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

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

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

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

View File

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

View 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>
.
</>
)}
</>
);
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View 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;
}

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View 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 {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>
);
}

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