Uninstall plugins from sidebar

Summary: Added UI for uninstalling plugins from sidebar. To avoid confusion between "disable" and "uninstall" and to reduce possibility of errors when plugins uninstalled accidentally by misclicks, I made it only possible to uninstall plugins after they are disabled. So for enabled plugins two steps are required for uninstalling.

Reviewed By: mweststrate

Differential Revision: D25454117

fbshipit-source-id: 28e67dc1ff2d39ad67e6d2770302a996affd9723
This commit is contained in:
Anton Nikolaev
2020-12-15 09:28:58 -08:00
committed by Facebook GitHub Bot
parent 97d37abbb2
commit df03ccbeab
7 changed files with 139 additions and 19 deletions

View File

@@ -9,11 +9,17 @@
import {Store} from '../reducers/index';
import {Logger} from '../fb-interfaces/Logger';
import {registerInstalledPlugins} from '../reducers/pluginManager';
import {
pluginFilesRemoved,
registerInstalledPlugins,
} from '../reducers/pluginManager';
import {
getInstalledPlugins,
cleanupOldInstalledPluginVersions,
removePlugin,
} from 'flipper-plugin-lib';
import {sideEffect} from '../utils/sideEffect';
import pMap from 'p-map';
const maxInstalledPluginVersionsToKeep = 2;
@@ -28,4 +34,26 @@ export default (store: Store, _logger: Logger) => {
window.requestIdleCallback(() => {
refreshInstalledPlugins(store);
});
sideEffect(
store,
{
name: 'removeUninstalledPluginFiles',
throttleMs: 1000,
fireImmediately: true,
},
(state) => state.pluginManager.removedPlugins,
(removedPlugins) => {
pMap(removedPlugins, (p) => {
removePlugin(p.name)
.then(() => pluginFilesRemoved(p))
.catch((e) =>
console.error(
`Error while removing files of uninstalled plugin ${p.title}`,
e,
),
);
}).then(() => refreshInstalledPlugins(store));
},
);
};

View File

@@ -268,7 +268,7 @@ export default function createTableNativePlugin(id: string, title: string) {
source: '',
main: '',
entry: '',
isDefault: false,
isDefault: true,
};
static defaultPersistedState: PersistedState = {

View File

@@ -12,7 +12,7 @@ import {PluginDetails} from 'flipper-plugin-lib';
test('reduce empty registerInstalledPlugins', () => {
const result = reducer(undefined, registerInstalledPlugins([]));
expect(result).toEqual({installedPlugins: []});
expect(result).toEqual({installedPlugins: [], removedPlugins: []});
});
const EXAMPLE_PLUGIN = {
@@ -33,8 +33,9 @@ test('reduce registerInstalledPlugins, clear again', () => {
const result = reducer(undefined, registerInstalledPlugins([EXAMPLE_PLUGIN]));
expect(result).toEqual({
installedPlugins: [EXAMPLE_PLUGIN],
removedPlugins: [],
});
const result2 = reducer(result, registerInstalledPlugins([]));
expect(result2).toEqual({installedPlugins: []});
expect(result2).toEqual({installedPlugins: [], removedPlugins: []});
});

View File

@@ -9,18 +9,33 @@
import {Actions} from './';
import {PluginDetails} from 'flipper-plugin-lib';
import {produce} from 'immer';
import {PluginDefinition} from '../plugin';
export type State = {
installedPlugins: PluginDetails[];
removedPlugins: PluginDetails[];
};
export type Action = {
type: 'REGISTER_INSTALLED_PLUGINS';
payload: PluginDetails[];
};
export type Action =
| {
type: 'REGISTER_INSTALLED_PLUGINS';
payload: PluginDetails[];
}
| {
type: 'PLUGIN_FILES_REMOVED';
payload: PluginDetails;
}
| {
// Implemented by rootReducer in `store.tsx`
type: 'UNINSTALL_PLUGIN';
payload: PluginDefinition;
};
const INITIAL_STATE: State = {
installedPlugins: [],
// plugins which were uninstalled recently and require file cleanup
removedPlugins: [],
};
export default function reducer(
@@ -32,6 +47,13 @@ export default function reducer(
...state,
installedPlugins: action.payload,
};
} else if (action.type === 'PLUGIN_FILES_REMOVED') {
const plugin = action.payload;
return produce(state, (draft) => {
draft.removedPlugins = draft.removedPlugins.filter(
(p) => p.id === plugin.id,
);
});
} else {
return state;
}
@@ -41,3 +63,13 @@ export const registerInstalledPlugins = (payload: PluginDetails[]): Action => ({
type: 'REGISTER_INSTALLED_PLUGINS',
payload,
});
export const pluginFilesRemoved = (payload: PluginDetails): Action => ({
type: 'PLUGIN_FILES_REMOVED',
payload,
});
export const uninstallPlugin = (payload: PluginDefinition): Action => ({
type: 'UNINSTALL_PLUGIN',
payload,
});

View File

@@ -10,7 +10,7 @@
import React, {memo, useCallback, useEffect, useRef, useState} from 'react';
import {Badge, Button, Menu, Tooltip, Typography} from 'antd';
import {InfoIcon, SidebarTitle} from '../LeftSidebar';
import {PlusOutlined, MinusOutlined} from '@ant-design/icons';
import {PlusOutlined, MinusOutlined, DeleteOutlined} from '@ant-design/icons';
import {Glyph, Layout, styled} from '../../ui';
import {theme, NUX, Tracked} from 'flipper-plugin';
import {useDispatch, useStore} from '../../utils/useStore';
@@ -26,6 +26,7 @@ import {useMemoize} from '../../utils/useMemoize';
import MetroDevice from '../../devices/MetroDevice';
import {DownloadablePluginDetails} from 'plugin-lib/lib';
import {startPluginDownload} from '../../reducers/pluginDownloads';
import {uninstallPlugin} from '../../reducers/pluginManager';
const {SubMenu} = Menu;
const {Text} = Typography;
@@ -108,6 +109,13 @@ export const PluginList = memo(function PluginList({
},
[uninstalledPlugins, dispatch],
);
const handleUninstallPlugin = useCallback(
(id: string) => {
const plugin = disabledPlugins.find((p) => p.id === id)!;
dispatch(uninstallPlugin(plugin));
},
[disabledPlugins, dispatch],
);
return (
<Layout.Container>
<SidebarTitle>Plugins</SidebarTitle>
@@ -194,12 +202,29 @@ export const PluginList = memo(function PluginList({
scrollTo={plugin.id === connections.selectedPlugin}
tooltip={getPluginTooltip(plugin.details)}
actions={
<ActionButton
id={plugin.id}
title="Enable plugin"
onClick={handleStarPlugin}
icon={<PlusOutlined size={16} style={{marginRight: 0}} />}
/>
<>
{!plugin.details.isDefault && (
<ActionButton
id={plugin.id}
title="Uninstall plugin"
onClick={handleUninstallPlugin}
icon={
<DeleteOutlined
size={16}
style={{marginRight: 0}}
/>
}
/>
)}
<ActionButton
id={plugin.id}
title="Enable plugin"
onClick={handleStarPlugin}
icon={
<PlusOutlined size={16} style={{marginRight: 0}} />
}
/>
</>
}
disabled
/>

View File

@@ -82,6 +82,9 @@ export function rootReducer(
} else {
return updateClientPlugin(state, plugin, enablePlugin);
}
} else if (action.type === 'UNINSTALL_PLUGIN' && state) {
const plugin = action.payload;
return uninstallPlugin(state, plugin);
}
// otherwise
@@ -173,6 +176,24 @@ function updateClientPlugin(
});
}
function uninstallPlugin(state: StoreState, plugin: PluginDefinition) {
const clients = state.connections.clients;
return produce(state, (draft) => {
clients.forEach((client) => {
stopPlugin(client, plugin.id);
const pluginKey = getPluginKey(
client.id,
{serial: client.query.device_id},
plugin.id,
);
delete draft.pluginMessageQueue[pluginKey];
});
cleanupPluginStates(draft.pluginStates, plugin.id);
draft.plugins.clientPlugins.delete(plugin.id);
draft.pluginManager.removedPlugins.push(plugin.details);
});
}
function updateDevicePlugin(state: StoreState, plugin: DevicePluginDefinition) {
const devices = state.connections.devices;
return produce(state, (draft) => {

View File

@@ -140,8 +140,12 @@ export async function getInstalledPlugins(): Promise<PluginDetails[]> {
versionDirs
.filter(([_, versionDirs]) => versionDirs.length > 0)
.map(([_, versionDirs]) => versionDirs[0]),
(latestVersionDir) => getPluginDetailsFromDir(latestVersionDir),
);
(latestVersionDir) =>
getPluginDetailsFromDir(latestVersionDir).catch((err) => {
console.error(`Failed to load plugin from ${latestVersionDir}`, err);
return null;
}),
).then((plugins) => plugins.filter(notNull));
}
export async function cleanupOldInstalledPluginVersions(
@@ -172,11 +176,20 @@ export async function moveInstalledPluginsFromLegacyDir() {
fs
.lstat(dir)
.then((lstat) => lstat.isDirectory())
.catch(() => Promise.resolve(false)),
.catch(() => false),
),
)
.then((dirs) =>
pmap(dirs, (dir) => getPluginDetailsFromDir(dir).catch(() => null)),
pmap(dirs, (dir) =>
getPluginDetailsFromDir(dir).catch(async (err) => {
console.error(
`Failed to load plugin from ${dir} on moving legacy plugins. Removing it.`,
err,
);
fs.remove(dir);
return null;
}),
),
)
.then((plugins) =>
pmap(plugins.filter(notNull), (plugin) =>