/** * 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 { FlexColumn, styled, ManagedTable_immutable, Toolbar, SearchInput, SearchBox, Button, colors, Spacer, TableRows_immutable, FlexRow, Glyph, Link, Text, LoadingIndicator, Tooltip, } from 'flipper'; import React, {useCallback, useState, useMemo, useEffect} from 'react'; import {List} from 'immutable'; import {SearchIndex} from 'algoliasearch'; import {SearchResponse} from '@algolia/client-search'; import path from 'path'; import fs from 'fs-extra'; import {reportPlatformFailures, reportUsage} from '../../utils/metrics'; import restartFlipper from '../../utils/restartFlipper'; import { PluginMap, PluginDefinition, registerInstalledPlugins, } from '../../reducers/pluginManager'; import { PLUGIN_DIR, readInstalledPlugins, provideSearchIndex, findPluginUpdates as _findPluginUpdates, UpdateResult, installPluginFromNpm, } from '../../utils/pluginManager'; import {State as AppState} from '../../reducers'; import {connect} from 'react-redux'; import {Dispatch, Action} from 'redux'; import PluginPackageInstaller from './PluginPackageInstaller'; const TAG = 'PluginInstaller'; const EllipsisText = styled(Text)({ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', }); const columnSizes = { name: '25%', version: '10%', description: 'flex', install: '15%', }; const columns = { name: { value: 'Name', }, version: { value: 'Version', }, description: { value: 'Description', }, install: { value: '', }, }; const Container = styled(FlexColumn)({ height: 300, backgroundColor: colors.white, borderRadius: 4, overflow: 'hidden', border: `1px solid ${colors.macOSTitleBarButtonBorder}`, }); const RestartBar = styled(FlexColumn)({ backgroundColor: colors.red, color: colors.white, fontWeight: 500, padding: 10, cursor: 'pointer', textAlign: 'center', }); type PropsFromState = { installedPlugins: PluginMap; }; type DispatchFromProps = { refreshInstalledPlugins: () => void; }; type OwnProps = { searchIndexFactory: () => SearchIndex; autoHeight: boolean; findPluginUpdates: ( currentPlugins: PluginMap, ) => Promise<[string, UpdateResult][]>; }; type Props = OwnProps & PropsFromState & DispatchFromProps; const defaultProps: OwnProps = { searchIndexFactory: provideSearchIndex, autoHeight: false, findPluginUpdates: _findPluginUpdates, }; type UpdatablePlugin = { updateStatus: UpdateResult; }; type UpdatablePluginDefinition = PluginDefinition & UpdatablePlugin; // exported for testing export function annotatePluginsWithUpdates( installedPlugins: Map, updates: Map, ): Map { const annotated: Array<[string, UpdatablePluginDefinition]> = Array.from( installedPlugins.entries(), ).map(([key, value]) => { const updateStatus = updates.get(key) || {kind: 'up-to-date'}; return [key, {...value, updateStatus: updateStatus}]; }); return new Map(annotated); } const PluginInstaller = function props(props: Props) { const [restartRequired, setRestartRequired] = useState(false); const [query, setQuery] = useState(''); const onInstall = useCallback(async () => { props.refreshInstalledPlugins(); setRestartRequired(true); }, []); const rows = useNPMSearch( query, setQuery, props.searchIndexFactory, props.installedPlugins, onInstall, props.findPluginUpdates, ); const restartApp = useCallback(() => { restartFlipper(); }, []); return ( <> {restartRequired && ( To activate this plugin, Flipper needs to restart. Click here to restart! )} setQuery(e.target.value)} value={query} placeholder="Search Flipper plugins..." /> ); }; PluginInstaller.defaultProps = defaultProps; const TableButton = styled(Button)({ marginTop: 2, }); const Spinner = styled(LoadingIndicator)({ marginTop: 6, }); const AlignedGlyph = styled(Glyph)({ marginTop: 6, }); function liftUpdatable(val: PluginDefinition): UpdatablePluginDefinition { return { ...val, updateStatus: {kind: 'up-to-date'}, }; } function InstallButton(props: { name: string; version: string; onInstall: () => void; installed: boolean; updateStatus: UpdateResult; }) { type InstallAction = | {kind: 'Install'; error?: string} | {kind: 'Waiting'} | {kind: 'Remove'; error?: string} | {kind: 'Update'; error?: string}; const catchError = ( actionKind: 'Install' | 'Remove' | 'Update', fn: () => Promise, ) => async () => { try { await fn(); } catch (err) { console.error(err); setAction({kind: actionKind, error: err.toString()}); } }; const mkInstallCallback = (action: 'Install' | 'Update') => catchError(action, async () => { reportUsage( action === 'Install' ? `${TAG}:install` : `${TAG}:update`, undefined, props.name, ); setAction({kind: 'Waiting'}); await installPluginFromNpm(props.name); props.onInstall(); setAction({kind: 'Remove'}); }); const performInstall = useCallback(mkInstallCallback('Install'), [ props.name, props.version, ]); const performUpdate = useCallback(mkInstallCallback('Update'), [ props.name, props.version, ]); const performRemove = useCallback( catchError('Remove', async () => { reportUsage(`${TAG}:remove`, undefined, props.name); setAction({kind: 'Waiting'}); await fs.remove(path.join(PLUGIN_DIR, props.name)); props.onInstall(); setAction({kind: 'Install'}); }), [props.name], ); const [action, setAction] = useState( props.updateStatus.kind === 'update-available' ? {kind: 'Update'} : props.installed ? {kind: 'Remove'} : {kind: 'Install'}, ); if (action.kind === 'Waiting') { return ; } if ((action.kind === 'Install' || action.kind === 'Remove') && action.error) { } const button = ( { switch (action.kind) { case 'Install': reportPlatformFailures(performInstall(), `${TAG}:install`); break; case 'Remove': reportPlatformFailures(performRemove(), `${TAG}:remove`); break; case 'Update': reportPlatformFailures(performUpdate(), `${TAG}:update`); break; } }}> {action.kind} ); if (action.error) { const glyph = ( ); return ( {button} ); } else { return button; } } function useNPMSearch( query: string, setQuery: (query: string) => void, searchClientFactory: () => SearchIndex, installedPlugins: Map, onInstall: () => Promise, findPluginUpdates: ( currentPlugins: PluginMap, ) => Promise<[string, UpdateResult][]>, ): TableRows_immutable { const index = useMemo(searchClientFactory, []); useEffect(() => { reportUsage(`${TAG}:open`); }, []); const createRow = useCallback( (h: UpdatablePluginDefinition) => ({ key: h.name, columns: { name: { value: ( {h.name.replace(/^flipper-plugin-/, '')} ), }, version: { value: {h.version}, align: 'flex-end' as 'flex-end', }, description: { value: ( {h.description} ), }, install: { value: ( ), align: 'center' as 'center', }, }, }), [installedPlugins], ); const [searchResults, setSearchResults] = useState< UpdatablePluginDefinition[] >([]); const [ updateAnnotatedInstalledPlugins, setUpdateAnnotatedInstalledPlugins, ] = useState>(new Map()); useEffect(() => { (async () => { let cancelled = false; const {hits} = await reportPlatformFailures( index.search('', { query, filters: 'keywords:flipper-plugin', hitsPerPage: 20, }) as Promise>, `${TAG}:queryIndex`, ); if (cancelled) { return; } setSearchResults( hits.filter(hit => !installedPlugins.has(hit.name)).map(liftUpdatable), ); // Clean up: if query changes while we're searching, abandon results. return () => { cancelled = true; }; })(); }, [query, installedPlugins]); useEffect(() => { (async () => { const updates = new Map(await findPluginUpdates(installedPlugins)); setUpdateAnnotatedInstalledPlugins( annotatePluginsWithUpdates(installedPlugins, updates), ); })(); }, [installedPlugins]); const results = Array.from(updateAnnotatedInstalledPlugins.values()).concat( searchResults, ); return List(results.map(createRow)); } export default connect( ({pluginManager: {installedPlugins}}) => ({ installedPlugins, }), (dispatch: Dispatch>) => ({ refreshInstalledPlugins: () => { readInstalledPlugins().then(plugins => dispatch(registerInstalledPlugins(plugins)), ); }, }), )(PluginInstaller);