Move local plugin discovery to dispatcher/redux store

Summary: In order to have update notifications, this must live outside the UI component, but it also gives some additional benefits like better testability of previously effectful UI.

Reviewed By: jknoxville

Differential Revision: D18173166

fbshipit-source-id: 1cacb6c7893423a7920a6620dfb76e631caba101
This commit is contained in:
Pascal Hartig
2019-11-05 05:27:38 -08:00
committed by Facebook Github Bot
parent 42a77094f4
commit 432bb1b00a
6 changed files with 170 additions and 42 deletions

View File

@@ -30,12 +30,14 @@ import {List} from 'immutable';
import algoliasearch from 'algoliasearch';
import path from 'path';
import fs from 'fs-extra';
import {homedir} from 'os';
import {PluginManager as PM} from 'live-plugin-manager';
import {reportPlatformFailures, reportUsage} from '../utils/metrics';
import restartFlipper from '../utils/restartFlipper';
import {PluginMap, PluginDefinition} from '../reducers/pluginManager';
import {PLUGIN_DIR} from '../dispatcher/pluginManager';
import {State as AppState} from '../reducers';
import {connect} from 'react-redux';
const PLUGIN_DIR = path.join(homedir(), '.flipper', 'thirdparty');
const ALGOLIA_APPLICATION_ID = 'OFCNCOG2CU';
const ALGOLIA_API_KEY = 'f54e21fa3a2a0160595bb058179bfb1e';
const TAG = 'PluginInstaller';
@@ -43,12 +45,6 @@ const PluginManager = new PM({
ignoredDependencies: ['flipper', 'react', 'react-dom', '@types/*'],
});
export type PluginDefinition = {
name: string;
version: string;
description: string;
};
const EllipsisText = styled(Text)({
overflow: 'hidden',
textOverflow: 'ellipsis',
@@ -94,18 +90,23 @@ const RestartBar = styled(FlexColumn)({
textAlign: 'center',
});
type Props = {
type PropsFromState = {
installedPlugins: PluginMap;
};
type OwnProps = {
searchIndexFactory: () => algoliasearch.Index;
getInstalledPlugins: () => Promise<Map<string, PluginDefinition>>;
autoHeight: boolean;
};
type Props = OwnProps & PropsFromState;
const defaultProps: Props = {
searchIndexFactory: () => {
const client = algoliasearch(ALGOLIA_APPLICATION_ID, ALGOLIA_API_KEY);
return client.initIndex('npm-search');
},
getInstalledPlugins: _getInstalledPlugins,
installedPlugins: new Map(),
autoHeight: false,
};
@@ -117,7 +118,8 @@ const PluginInstaller = function props(props: Props) {
query,
setQuery,
props.searchIndexFactory,
props.getInstalledPlugins,
// TODO(T56693735): Refactor this to directly take props.
async () => props.installedPlugins,
);
const restartApp = useCallback(() => {
restartFlipper();
@@ -155,7 +157,6 @@ const PluginInstaller = function props(props: Props) {
);
};
PluginInstaller.defaultProps = defaultProps;
export default PluginInstaller;
const TableButton = styled(Button)({
marginTop: 2,
@@ -365,32 +366,9 @@ function useNPMSearch(
return List(results.map(createRow));
}
async function _getInstalledPlugins(): Promise<Map<string, PluginDefinition>> {
const pluginDirExists = await fs.pathExists(PLUGIN_DIR);
if (!pluginDirExists) {
return new Map();
}
const dirs = await fs.readdir(PLUGIN_DIR);
const plugins = await Promise.all<[string, PluginDefinition]>(
dirs.map(
name =>
new Promise(async (resolve, reject) => {
if (!(await fs.lstat(path.join(PLUGIN_DIR, name))).isDirectory()) {
return resolve(undefined);
}
const packageJSON = await fs.readFile(
path.join(PLUGIN_DIR, name, 'package.json'),
);
try {
resolve([name, JSON.parse(packageJSON.toString())]);
} catch (e) {
reject(e);
}
}),
),
);
return new Map(plugins.filter(Boolean));
}
export default connect<PropsFromState, {}, OwnProps, AppState>(
({pluginManager: {installedPlugins}}) => ({
installedPlugins,
}),
{},
)(PluginInstaller);

View File

@@ -16,6 +16,7 @@ import server from './server';
import notifications from './notifications';
import plugins from './plugins';
import user from './user';
import pluginManager from './pluginManager';
import {Logger} from '../fb-interfaces/Logger';
import {Store} from '../reducers/index';
@@ -33,6 +34,7 @@ export default function(store: Store, logger: Logger): () => Promise<void> {
notifications,
plugins,
user,
pluginManager,
].filter(notNull);
const globalCleanup = dispatchers
.map(dispatcher => dispatcher(store, logger))

View File

@@ -0,0 +1,57 @@
/**
* 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 {Store} from '../reducers/index';
import {Logger} from '../fb-interfaces/Logger';
import path from 'path';
import fs from 'fs-extra';
import {homedir} from 'os';
import {
registerInstalledPlugins,
PluginMap,
PluginDefinition,
} from '../reducers/pluginManager';
export const PLUGIN_DIR = path.join(homedir(), '.flipper', 'thirdparty');
async function getInstalledPlugins(): Promise<PluginMap> {
const pluginDirExists = await fs.pathExists(PLUGIN_DIR);
if (!pluginDirExists) {
return new Map();
}
const dirs = await fs.readdir(PLUGIN_DIR);
const plugins = await Promise.all<[string, PluginDefinition]>(
dirs.map(
name =>
new Promise(async (resolve, reject) => {
if (!(await fs.lstat(path.join(PLUGIN_DIR, name))).isDirectory()) {
return resolve(undefined);
}
const packageJSON = await fs.readFile(
path.join(PLUGIN_DIR, name, 'package.json'),
);
try {
resolve([name, JSON.parse(packageJSON.toString())]);
} catch (e) {
reject(e);
}
}),
),
);
return new Map(plugins.filter(Boolean));
}
export default (store: Store, _logger: Logger) => {
getInstalledPlugins().then(plugins =>
store.dispatch(registerInstalledPlugins(plugins)),
);
};

View File

@@ -0,0 +1,34 @@
/**
* 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 {default as reducer, registerInstalledPlugins} from '../pluginManager';
test('reduce empty registerInstalledPlugins', () => {
const result = reducer(undefined, registerInstalledPlugins(new Map()));
expect(result).toEqual({installedPlugins: new Map()});
});
const EXAMPLE_PLUGIN = {
name: 'test',
version: '0.1',
description: 'my test plugin',
};
test('reduce registerInstalledPlugins, clear again', () => {
const result = reducer(
undefined,
registerInstalledPlugins(new Map([['test', EXAMPLE_PLUGIN]])),
);
expect(result).toEqual({
installedPlugins: new Map([['test', EXAMPLE_PLUGIN]]),
});
const result2 = reducer(result, registerInstalledPlugins(new Map()));
expect(result2).toEqual({installedPlugins: new Map()});
});

View File

@@ -36,6 +36,10 @@ import settings, {
Settings as SettingsState,
Action as SettingsAction,
} from './settings';
import pluginManager, {
State as PluginManagerState,
Action as PluginManagerAction,
} from './pluginManager';
import user, {State as UserState, Action as UserAction} from './user';
import JsonFileStorage from '../utils/jsonFileReduxPersistStorage';
import os from 'os';
@@ -56,6 +60,7 @@ export type Actions =
| UserAction
| SettingsAction
| SupportFormAction
| PluginManagerAction
| {type: 'INIT'};
export type State = {
@@ -67,6 +72,7 @@ export type State = {
user: UserState & PersistPartial;
settingsState: SettingsState & PersistPartial;
supportForm: SupportFormState;
pluginManager: PluginManagerState;
};
export type Store = ReduxStore<State, Actions>;
@@ -106,6 +112,7 @@ export default combineReducers<State, Actions>({
),
plugins,
supportForm,
pluginManager,
user: persistReducer(
{
key: 'user',

View File

@@ -0,0 +1,50 @@
/**
* 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 {Actions} from './';
export type PluginDefinition = {
name: string;
version: string;
description: string;
};
export type PluginMap = Map<string, PluginDefinition>;
export type State = {
installedPlugins: PluginMap;
};
export type Action = {
type: 'REGISTER_INSTALLED_PLUGINS';
payload: PluginMap;
};
const INITIAL_STATE: State = {
installedPlugins: new Map(),
};
export default function reducer(
state: State = INITIAL_STATE,
action: Actions,
): State {
if (action.type === 'REGISTER_INSTALLED_PLUGINS') {
return {
...state,
installedPlugins: action.payload,
};
} else {
return state;
}
}
export const registerInstalledPlugins = (payload: PluginMap): Action => ({
type: 'REGISTER_INSTALLED_PLUGINS',
payload,
});