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:
committed by
Facebook GitHub Bot
parent
97d37abbb2
commit
df03ccbeab
@@ -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));
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -268,7 +268,7 @@ export default function createTableNativePlugin(id: string, title: string) {
|
||||
source: '',
|
||||
main: '',
|
||||
entry: '',
|
||||
isDefault: false,
|
||||
isDefault: true,
|
||||
};
|
||||
|
||||
static defaultPersistedState: PersistedState = {
|
||||
|
||||
@@ -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: []});
|
||||
});
|
||||
|
||||
@@ -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 = {
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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={
|
||||
<>
|
||||
{!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}} />}
|
||||
icon={
|
||||
<PlusOutlined size={16} style={{marginRight: 0}} />
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
disabled
|
||||
/>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user