Files
flipper/desktop/flipper-ui-core/src/PluginContainer.tsx
Andrey Goncharov fd380a4c1e Use BaseDevice from flipper-frontend-core in flipper-ui-core
Summary: Use BasDevice definition from flipper-frontend-core in flipper-ui-core and remove the redundant definition from flipper-ui-core

Reviewed By: lblasa

Differential Revision: D37234785

fbshipit-source-id: 6e768090a197c1d2c49cb1cd573acea12fb65d24
2022-06-20 12:18:40 -07:00

442 lines
12 KiB
TypeScript

/**
* Copyright (c) Meta Platforms, Inc. and 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 {FlipperPlugin, FlipperDevicePlugin} from './plugin';
import {Logger, isTest} from 'flipper-common';
import {BaseDevice} from 'flipper-frontend-core';
import {pluginKey as getPluginKey} from './utils/pluginKey';
import Client from './Client';
import {
ErrorBoundary,
FlexColumn,
FlexRow,
colors,
styled,
Glyph,
Label,
VBox,
View,
} from './ui';
import {StaticView, setStaticView} from './reducers/connections';
import {switchPlugin} from './reducers/pluginManager';
import React, {PureComponent} from 'react';
import {connect, ReactReduxContext, ReactReduxContextValue} from 'react-redux';
import {selectPlugin} from './reducers/connections';
import {State as Store, MiddlewareAPI} from './reducers/index';
import {Message} from './reducers/pluginMessageQueue';
import {IdlerImpl} from './utils/Idler';
import {processMessageQueue} from './utils/messageQueue';
import {Layout} from './ui';
import {theme, _SandyPluginRenderer} from 'flipper-plugin';
import {
ActivePluginListItem,
isDevicePlugin,
isDevicePluginDefinition,
} from './utils/pluginUtils';
import {ContentContainer} from './sandy-chrome/ContentContainer';
import {Alert, Typography} from 'antd';
import semver from 'semver';
import {loadPlugin} from './reducers/pluginManager';
import {produce} from 'immer';
import {reportUsage, InstalledPluginDetails} from 'flipper-common';
import {PluginInfo} from './chrome/fb-stubs/PluginInfo';
import {getActiveClient, getActivePlugin} from './selectors/connections';
import {AnyAction} from 'redux';
const {Text, Link} = Typography;
export const SidebarContainer = styled(FlexRow)({
backgroundColor: theme.backgroundWash,
height: '100%',
overflow: 'auto',
});
const Waiting = styled(FlexColumn)({
width: '100%',
height: '100%',
flexGrow: 1,
alignItems: 'center',
justifyContent: 'center',
textAlign: 'center',
});
function ProgressBar({progress}: {progress: number}) {
return (
<ProgressBarContainer>
<ProgressBarBar progress={progress} />
</ProgressBarContainer>
);
}
const ProgressBarContainer = styled.div({
border: `1px solid ${colors.cyan}`,
borderRadius: 4,
width: 300,
});
const ProgressBarBar = styled.div<{progress: number}>(({progress}) => ({
background: colors.cyan,
width: `${Math.min(100, Math.round(progress * 100))}%`,
height: 8,
}));
type OwnProps = {
logger: Logger;
};
type StateFromProps = {
activePlugin: ActivePluginListItem | null;
target: Client | BaseDevice | null;
pluginKey: string | null;
deepLinkPayload: unknown;
pendingMessages: Message[] | undefined;
latestInstalledVersion: InstalledPluginDetails | undefined;
};
type DispatchFromProps = {
selectPlugin: typeof selectPlugin;
setStaticView: (payload: StaticView) => void;
enablePlugin: typeof switchPlugin;
loadPlugin: typeof loadPlugin;
};
type Props = StateFromProps & DispatchFromProps & OwnProps;
type State = {
progress: {current: number; total: number};
autoUpdateAlertSuppressed: Set<string>;
};
class PluginContainer extends PureComponent<Props, State> {
static contextType: React.Context<ReactReduxContextValue<any, AnyAction>> =
ReactReduxContext;
constructor(props: Props) {
super(props);
this.reloadPlugin = this.reloadPlugin.bind(this);
}
plugin:
| FlipperPlugin<any, any, any>
| FlipperDevicePlugin<any, any, any>
| null
| undefined;
idler?: IdlerImpl;
pluginBeingProcessed: string | null = null;
state = {
progress: {current: 0, total: 0},
autoUpdateAlertSuppressed: new Set<string>(),
};
get store(): MiddlewareAPI {
return this.context.store;
}
componentWillUnmount() {
if (this.plugin) {
this.plugin._teardown();
this.plugin = null;
}
this.cancelCurrentQueue();
}
componentDidMount() {
this.processMessageQueue();
}
componentDidUpdate() {
this.processMessageQueue();
// make sure deeplinks are propagated
const {deepLinkPayload, target, activePlugin} = this.props;
if (deepLinkPayload && activePlugin && target) {
target.sandyPluginStates
.get(activePlugin.details.id)
?.triggerDeepLink(deepLinkPayload);
}
}
processMessageQueue() {
const {pluginKey, pendingMessages, activePlugin, target} = this.props;
if (pluginKey !== this.pluginBeingProcessed) {
this.pluginBeingProcessed = pluginKey;
this.cancelCurrentQueue();
this.setState((state) =>
produce(state, (draft) => {
draft.progress = {current: 0, total: 0};
}),
);
// device plugins don't have connections so no message queues
if (
!activePlugin ||
activePlugin.status !== 'enabled' ||
isDevicePluginDefinition(activePlugin.definition)
) {
return;
}
if (
target instanceof Client &&
activePlugin &&
pluginKey &&
pendingMessages?.length
) {
const start = Date.now();
this.idler = new IdlerImpl();
processMessageQueue(
target.sandyPluginStates.get(activePlugin.definition.id)!,
pluginKey,
this.store,
(progress) => {
this.setState((state) =>
produce(state, (draft) => {
draft.progress = progress;
}),
);
},
this.idler,
)
.then((completed) => {
const duration = Date.now() - start;
this.props.logger.track(
'duration',
'queue-processing-before-plugin-open',
{
completed,
duration,
},
activePlugin.definition.id,
);
})
.catch((err) =>
console.error('Error while processing plugin message queue', err),
);
}
}
}
cancelCurrentQueue() {
if (this.idler && !this.idler.isCancelled()) {
this.idler.cancel();
}
}
render() {
const {activePlugin, pendingMessages} = this.props;
if (!activePlugin) {
return this.renderNoPluginActive();
}
if (activePlugin.status !== 'enabled') {
return this.renderPluginInfo();
}
if (!pendingMessages || pendingMessages.length === 0) {
return this.renderPlugin();
}
return this.renderPluginLoader();
}
renderPluginInfo() {
if (isTest()) {
// Plugin info uses Antd animations, generating a gazillion warnings
return 'Stubbed plugin info';
}
return <PluginInfo />;
}
renderPluginLoader() {
return (
<View grow>
<Waiting>
<VBox>
<Glyph
name="dashboard"
variant="outline"
size={24}
color={colors.light30}
/>
</VBox>
<VBox>
<Label>
Processing {this.state.progress.total} events for{' '}
{this.props.activePlugin?.details?.id ?? 'plugin'}
</Label>
</VBox>
<VBox>
<ProgressBar
progress={this.state.progress.current / this.state.progress.total}
/>
</VBox>
</Waiting>
</View>
);
}
renderNoPluginActive() {
if (isTest()) {
return <>No plugin selected</>; // to keep 'nothing' clearly recognisable in unit tests
}
return (
<View grow>
<Waiting>
<VBox>
<Glyph
name="cup"
variant="outline"
size={24}
color={colors.light30}
/>
</VBox>
<VBox>
<Label>No plugin selected</Label>
</VBox>
</Waiting>
</View>
);
}
reloadPlugin() {
const {loadPlugin, latestInstalledVersion} = this.props;
if (latestInstalledVersion) {
reportUsage(
'plugin-auto-update:alert:reloadClicked',
{
version: latestInstalledVersion.version,
},
latestInstalledVersion.id,
);
loadPlugin({
plugin: latestInstalledVersion,
enable: false,
notifyIfFailed: true,
});
}
}
renderPlugin() {
const {activePlugin, pluginKey, target, latestInstalledVersion} =
this.props;
if (
!activePlugin ||
!target ||
!pluginKey ||
activePlugin.status !== 'enabled'
) {
console.warn(`No selected plugin. Rendering empty!`);
return this.renderNoPluginActive();
}
const showUpdateAlert =
latestInstalledVersion &&
activePlugin &&
!this.state.autoUpdateAlertSuppressed.has(
`${latestInstalledVersion.name}@${latestInstalledVersion.version}`,
) &&
semver.gt(
latestInstalledVersion.version,
activePlugin.definition.version,
);
// Make sure we throw away the container for different pluginKey!
const instance = target.sandyPluginStates.get(activePlugin.definition.id);
if (!instance) {
// happens if we selected a plugin that is not enabled on a specific app or not supported on a specific device.
return this.renderNoPluginActive();
}
return (
<Layout.Top>
<div>
{showUpdateAlert && (
<Alert
message={
<Text>
Plugin "{activePlugin.definition.title}" v
{latestInstalledVersion?.version} is downloaded and ready to
install. <Link onClick={this.reloadPlugin}>Reload</Link> to
start using the new version.
</Text>
}
type="info"
onClose={() =>
this.setState((state) =>
produce(state, (draft) => {
draft.autoUpdateAlertSuppressed.add(
`${latestInstalledVersion?.name}@${latestInstalledVersion?.version}`,
);
}),
)
}
style={{marginBottom: theme.space.large}}
showIcon
closable
/>
)}
</div>
<Layout.Right>
<ErrorBoundary
heading={`Plugin "${
activePlugin.definition.title || 'Unknown'
}" encountered an error during render`}>
<ContentContainer>
<_SandyPluginRenderer key={pluginKey} plugin={instance} />
</ContentContainer>
</ErrorBoundary>
<SidebarContainer id="detailsSidebar" />
</Layout.Right>
</Layout.Top>
);
}
}
export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
(state: Store) => {
let pluginKey: string | null = null;
let target: BaseDevice | Client | null = null;
const {
connections: {selectedDevice, deepLinkPayload},
plugins: {installedPlugins},
pluginMessageQueue,
} = state;
const selectedClient = getActiveClient(state);
const activePlugin = getActivePlugin(state);
if (activePlugin) {
if (selectedDevice && isDevicePlugin(activePlugin)) {
target = selectedDevice;
pluginKey = getPluginKey(
selectedDevice.serial,
activePlugin.details.id,
);
} else if (selectedClient) {
target = selectedClient;
pluginKey = getPluginKey(selectedClient.id, activePlugin.details.id);
}
}
const pendingMessages = pluginKey
? pluginMessageQueue[pluginKey]
: undefined;
const s: StateFromProps = {
activePlugin,
target,
deepLinkPayload,
pluginKey,
pendingMessages,
latestInstalledVersion: installedPlugins.get(
activePlugin?.details?.name ?? '',
),
};
return s;
},
{
selectPlugin,
setStaticView,
enablePlugin: switchPlugin,
loadPlugin,
},
)(PluginContainer);