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

View File

@@ -16,6 +16,7 @@ import server from './server';
import notifications from './notifications'; import notifications from './notifications';
import plugins from './plugins'; import plugins from './plugins';
import user from './user'; import user from './user';
import pluginManager from './pluginManager';
import {Logger} from '../fb-interfaces/Logger'; import {Logger} from '../fb-interfaces/Logger';
import {Store} from '../reducers/index'; import {Store} from '../reducers/index';
@@ -33,6 +34,7 @@ export default function(store: Store, logger: Logger): () => Promise<void> {
notifications, notifications,
plugins, plugins,
user, user,
pluginManager,
].filter(notNull); ].filter(notNull);
const globalCleanup = dispatchers const globalCleanup = dispatchers
.map(dispatcher => dispatcher(store, logger)) .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, Settings as SettingsState,
Action as SettingsAction, Action as SettingsAction,
} from './settings'; } from './settings';
import pluginManager, {
State as PluginManagerState,
Action as PluginManagerAction,
} from './pluginManager';
import user, {State as UserState, Action as UserAction} from './user'; import user, {State as UserState, Action as UserAction} from './user';
import JsonFileStorage from '../utils/jsonFileReduxPersistStorage'; import JsonFileStorage from '../utils/jsonFileReduxPersistStorage';
import os from 'os'; import os from 'os';
@@ -56,6 +60,7 @@ export type Actions =
| UserAction | UserAction
| SettingsAction | SettingsAction
| SupportFormAction | SupportFormAction
| PluginManagerAction
| {type: 'INIT'}; | {type: 'INIT'};
export type State = { export type State = {
@@ -67,6 +72,7 @@ export type State = {
user: UserState & PersistPartial; user: UserState & PersistPartial;
settingsState: SettingsState & PersistPartial; settingsState: SettingsState & PersistPartial;
supportForm: SupportFormState; supportForm: SupportFormState;
pluginManager: PluginManagerState;
}; };
export type Store = ReduxStore<State, Actions>; export type Store = ReduxStore<State, Actions>;
@@ -106,6 +112,7 @@ export default combineReducers<State, Actions>({
), ),
plugins, plugins,
supportForm, supportForm,
pluginManager,
user: persistReducer( user: persistReducer(
{ {
key: 'user', 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,
});