Files
flipper/desktop/app/src/PluginContainer.tsx
Anton Nikolaev fa3ff83595 Rename star/unstar actions to enable/disable/switch
Summary:
Renamed actions "star" and "unstar" everywhere to "enable", "disable" and "switch". The logic behind original "star" action changed significantly, so this rename just makes everything much clearer.

Please note that as a part of rename persisted state fields "userStarredPlugins" and "userStarredDevicePlugins" were renamed. I've added a "redux-persist" migration for seamless transition.

Reviewed By: passy

Differential Revision: D26606459

fbshipit-source-id: 83ad475f9b0231194701c40a2cdbda36f02c3d10
2021-02-24 05:30:57 -08:00

626 lines
16 KiB
TypeScript

/**
* 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 {
FlipperPlugin,
FlipperDevicePlugin,
Props as PluginProps,
PluginDefinition,
isSandyPlugin,
} from './plugin';
import {Logger} from './fb-interfaces/Logger';
import BaseDevice from './devices/BaseDevice';
import {pluginKey as getPluginKey} from './reducers/pluginStates';
import Client from './Client';
import {
ErrorBoundary,
FlexColumn,
FlexRow,
colors,
styled,
Glyph,
Label,
VBox,
View,
} from './ui';
import {
StaticView,
setStaticView,
isPluginEnabled,
} from './reducers/connections';
import {switchPlugin} from './reducers/pluginManager';
import React, {PureComponent} from 'react';
import {connect, ReactReduxContext} from 'react-redux';
import {setPluginState} from './reducers/pluginStates';
import {Settings} from './reducers/settings';
import {selectPlugin} from './reducers/connections';
import {State as Store, MiddlewareAPI} from './reducers/index';
import {activateMenuItems} from './MenuBar';
import {Message} from './reducers/pluginMessageQueue';
import {IdlerImpl} from './utils/Idler';
import {processMessageQueue} from './utils/messageQueue';
import {ToggleButton, SmallText, Layout} from './ui';
import {theme, TrackingScope, _SandyPluginRenderer} from 'flipper-plugin';
import {isDevicePluginDefinition} from './utils/pluginUtils';
import {ContentContainer} from './sandy-chrome/ContentContainer';
import {Alert, Typography} from 'antd';
import {InstalledPluginDetails} from 'plugin-lib';
import semver from 'semver';
import {loadPlugin} from './reducers/pluginManager';
import {produce} from 'immer';
import {reportUsage} from './utils/metrics';
const {Text, Link} = Typography;
const Container = styled(FlexColumn)({
width: 0,
flexGrow: 1,
flexShrink: 1,
backgroundColor: colors.white,
});
export const SidebarContainer = styled(FlexRow)({
backgroundColor: colors.light02,
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;
isSandy?: boolean;
};
type StateFromProps = {
pluginState: Object;
activePlugin: PluginDefinition | undefined;
target: Client | BaseDevice | null;
pluginKey: string | null;
deepLinkPayload: unknown;
selectedApp: string | null;
isArchivedDevice: boolean;
pendingMessages: Message[] | undefined;
pluginIsEnabled: boolean;
settingsState: Settings;
latestInstalledVersion: InstalledPluginDetails | undefined;
};
type DispatchFromProps = {
selectPlugin: (payload: {
selectedPlugin: string | null;
selectedApp?: string | null;
deepLinkPayload: unknown;
}) => any;
setPluginState: (payload: {pluginKey: string; state: any}) => void;
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 = ReactReduxContext;
constructor(props: Props) {
super(props);
this.reloadPlugin = this.reloadPlugin.bind(this);
}
plugin:
| FlipperPlugin<any, any, any>
| FlipperDevicePlugin<any, any, any>
| null
| undefined;
refChanged = (
ref:
| FlipperPlugin<any, any, any>
| FlipperDevicePlugin<any, any, any>
| null
| undefined,
) => {
// N.B. for Sandy plugins this lifecycle is managed by PluginRenderer
if (this.plugin) {
this.plugin._teardown();
this.plugin = null;
}
if (ref && this.props.target) {
activateMenuItems(ref);
ref._init();
this.props.logger.trackTimeSince(`activePlugin-${ref.constructor.id}`);
this.plugin = ref;
}
};
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.id)
?.triggerDeepLink(deepLinkPayload);
}
}
processMessageQueue() {
const {
pluginKey,
pendingMessages,
activePlugin,
pluginIsEnabled,
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 || isDevicePluginDefinition(activePlugin)) {
return;
}
if (
pluginIsEnabled &&
target instanceof Client &&
activePlugin &&
(isSandyPlugin(activePlugin) || activePlugin.persistedStateReducer) &&
pluginKey &&
pendingMessages?.length
) {
const start = Date.now();
this.idler = new IdlerImpl();
processMessageQueue(
isSandyPlugin(activePlugin)
? target.sandyPluginStates.get(activePlugin.id)!
: activePlugin,
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.id,
);
});
}
}
}
cancelCurrentQueue() {
if (this.idler && !this.idler.isCancelled()) {
this.idler.cancel();
}
}
render() {
const {
activePlugin,
pluginKey,
target,
pendingMessages,
pluginIsEnabled,
} = this.props;
if (!activePlugin || !target || !pluginKey) {
return null;
}
if (!pluginIsEnabled) {
return this.renderPluginEnabler();
}
if (!pendingMessages || pendingMessages.length === 0) {
return this.renderPlugin();
}
return this.renderPluginLoader();
}
renderPluginEnabler() {
const activePlugin = this.props.activePlugin!;
return (
<View grow>
<Waiting>
<VBox>
<FlexRow>
<Label
style={{
fontSize: '16px',
color: colors.light30,
textTransform: 'uppercase',
}}>
{activePlugin.title}
</Label>
</FlexRow>
</VBox>
<VBox>
<ToggleButton
toggled={false}
onClick={() => {
this.props.enablePlugin({
plugin: activePlugin,
selectedApp: (this.props.target as Client)?.query?.app,
});
}}
large
/>
</VBox>
<VBox>
<SmallText>Click to enable this plugin</SmallText>
</VBox>
</Waiting>
</View>
);
}
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?.id ?? 'plugin'}
</Label>
</VBox>
<VBox>
<ProgressBar
progress={this.state.progress.current / this.state.progress.total}
/>
</VBox>
</Waiting>
</View>
);
}
renderNoPluginActive() {
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 {
pluginState,
setPluginState,
activePlugin,
pluginKey,
target,
isArchivedDevice,
selectedApp,
settingsState,
isSandy,
latestInstalledVersion,
} = this.props;
if (!activePlugin || !target || !pluginKey) {
console.warn(`No selected plugin. Rendering empty!`);
return this.renderNoPluginActive();
}
let pluginElement: null | React.ReactElement<any>;
const showUpdateAlert =
latestInstalledVersion &&
activePlugin &&
!this.state.autoUpdateAlertSuppressed.has(
`${latestInstalledVersion.name}@${latestInstalledVersion.version}`,
) &&
semver.gt(latestInstalledVersion.version, activePlugin.version);
if (isSandyPlugin(activePlugin)) {
// Make sure we throw away the container for different pluginKey!
const instance = target.sandyPluginStates.get(activePlugin.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();
}
pluginElement = (
<_SandyPluginRenderer key={pluginKey} plugin={instance} />
);
} else {
const props: PluginProps<Object> & {
key: string;
ref: (
ref:
| FlipperPlugin<any, any, any>
| FlipperDevicePlugin<any, any, any>
| null
| undefined,
) => void;
} = {
key: pluginKey,
logger: this.props.logger,
selectedApp,
persistedState: activePlugin.defaultPersistedState
? {
...activePlugin.defaultPersistedState,
...pluginState,
}
: pluginState,
setStaticView: (payload: StaticView) =>
this.props.setStaticView(payload),
setPersistedState: (state) => setPluginState({pluginKey, state}),
target,
deepLinkPayload: this.props.deepLinkPayload,
selectPlugin: (pluginID: string, deepLinkPayload: unknown) => {
const {target} = this.props;
// check if plugin will be available
if (
target instanceof Client &&
target.plugins.some((p) => p === pluginID)
) {
this.props.selectPlugin({
selectedPlugin: pluginID,
deepLinkPayload,
});
return true;
} else if (target instanceof BaseDevice) {
this.props.selectPlugin({
selectedPlugin: pluginID,
deepLinkPayload,
});
return true;
} else {
return false;
}
},
ref: this.refChanged,
isArchivedDevice,
settingsState,
};
pluginElement = (
<TrackingScope scope={'plugin:' + activePlugin.id}>
{React.createElement(activePlugin, props)}
</TrackingScope>
);
}
return isSandy ? (
<Layout.Top>
<div>
{showUpdateAlert && (
<Alert
message={
<Text>
Plugin "{activePlugin.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.title || 'Unknown'
}" encountered an error during render`}>
<ContentContainer>{pluginElement}</ContentContainer>
</ErrorBoundary>
<SidebarContainer id="detailsSidebar" />
</Layout.Right>
</Layout.Top>
) : (
<React.Fragment>
<Container key="plugin">
<ErrorBoundary
heading={`Plugin "${
activePlugin.title || 'Unknown'
}" encountered an error during render`}>
{pluginElement}
</ErrorBoundary>
</Container>
<SidebarContainer id="detailsSidebar" />
</React.Fragment>
);
}
}
export default connect<StateFromProps, DispatchFromProps, OwnProps, Store>(
({
connections: {
selectedPlugin,
selectedDevice,
selectedApp,
clients,
deepLinkPayload,
enabledPlugins,
enabledDevicePlugins,
},
pluginStates,
plugins: {devicePlugins, clientPlugins, installedPlugins},
pluginMessageQueue,
settingsState,
}) => {
let pluginKey = null;
let target = null;
let activePlugin: PluginDefinition | undefined;
let pluginIsEnabled = false;
if (selectedPlugin) {
activePlugin = devicePlugins.get(selectedPlugin);
if (selectedDevice && activePlugin) {
target = selectedDevice;
pluginKey = getPluginKey(selectedDevice.serial, activePlugin.id);
} else {
target =
clients.find((client: Client) => client.id === selectedApp) || null;
activePlugin = clientPlugins.get(selectedPlugin);
if (activePlugin && target) {
pluginKey = getPluginKey(target.id, activePlugin.id);
}
}
pluginIsEnabled =
activePlugin !== undefined &&
isPluginEnabled(
enabledPlugins,
enabledDevicePlugins,
selectedApp,
activePlugin.id,
);
}
const isArchivedDevice = !selectedDevice
? false
: selectedDevice.isArchived;
if (isArchivedDevice) {
pluginIsEnabled = true;
}
const pendingMessages = pluginKey
? pluginMessageQueue[pluginKey]
: undefined;
const s: StateFromProps = {
pluginState: pluginStates[pluginKey as string],
activePlugin: activePlugin,
target,
deepLinkPayload,
pluginKey,
isArchivedDevice,
selectedApp: selectedApp || null,
pendingMessages,
pluginIsEnabled,
settingsState,
latestInstalledVersion: installedPlugins.get(
activePlugin?.packageName ?? '',
),
};
return s;
},
{
setPluginState,
selectPlugin,
setStaticView,
enablePlugin: switchPlugin,
loadPlugin,
},
)(PluginContainer);