From ed5c2bd39f7a5f6dc6d1788dfb67fb1d3c5af9f2 Mon Sep 17 00:00:00 2001 From: Andrey Goncharov Date: Fri, 12 Nov 2021 07:12:18 -0800 Subject: [PATCH] Add plugin actions menu Summary: See D32311662 for details Reviewed By: mweststrate Differential Revision: D32329804 fbshipit-source-id: 26670353fdf8580643afcb8bc3493384146f5574 --- desktop/app/src/MenuBar.tsx | 92 ----------- desktop/app/src/RenderHost.tsx | 10 +- .../createMockFlipperWithPlugin.node.tsx.snap | 1 + desktop/app/src/chrome/PluginActionsMenu.tsx | 144 ++++++++++++++++++ .../app/src/electron/initializeElectron.tsx | 1 + desktop/app/src/reducers/connections.tsx | 16 ++ desktop/app/src/sandy-chrome/LeftRail.tsx | 4 +- .../sandy-chrome/appinspect/AppInspect.tsx | 3 + .../src/utils/createSandyPluginWrapper.tsx | 3 +- .../src/utils/flipperLibImplementation.tsx | 5 +- .../src/__tests__/test-utils.node.tsx | 1 - .../flipper-plugin/src/plugin/MenuEntry.tsx | 7 - .../public/seamammals/src/index_custom.tsx | 1 - docs/extending/flipper-plugin.mdx | 5 +- 14 files changed, 180 insertions(+), 113 deletions(-) create mode 100644 desktop/app/src/chrome/PluginActionsMenu.tsx diff --git a/desktop/app/src/MenuBar.tsx b/desktop/app/src/MenuBar.tsx index 486c975c6..38f0a7c40 100644 --- a/desktop/app/src/MenuBar.tsx +++ b/desktop/app/src/MenuBar.tsx @@ -19,11 +19,9 @@ import { import {setStaticView} from './reducers/connections'; import {Store} from './reducers/'; import electron, {MenuItemConstructorOptions} from 'electron'; -import {notNull} from './utils/typeUtils'; import constants from './fb-stubs/constants'; import {Logger} from 'flipper-common'; import { - NormalizedMenuEntry, _buildInMenuEntries, _wrapInteractionHandler, getFlipperLib, @@ -46,7 +44,6 @@ export type KeyboardAction = { action: string; label: string; accelerator?: string; - topLevelMenu: TopLevelMenu; }; export type KeyboardActions = Array; @@ -73,23 +70,6 @@ export function setupMenuBar( store, logger, ); - // collect all keyboard actions from all plugins - const registeredActions = new Set( - plugins - .map((plugin) => plugin.keyboardActions || []) - .flat() - .map((action: DefaultKeyboardAction | KeyboardAction) => - typeof action === 'string' ? _buildInMenuEntries[action] : action, - ) - .filter(notNull), - ); - - // add keyboard actions to - registeredActions.forEach((keyboardAction) => { - if (keyboardAction != null) { - appendMenuItem(template, keyboardAction); - } - }); // create actual menu instance const applicationMenu = electron.remote.Menu.buildFromTemplate(template); @@ -98,27 +78,6 @@ export function setupMenuBar( electron.remote.Menu.setApplicationMenu(applicationMenu); } -function appendMenuItem( - template: Array, - item: KeyboardAction, -) { - const keyboardAction = item; - if (keyboardAction == null) { - return; - } - const itemIndex = template.findIndex( - (menu) => menu.label === keyboardAction.topLevelMenu, - ); - if (itemIndex > -1 && template[itemIndex].submenu != null) { - (template[itemIndex].submenu as MenuItemConstructorOptions[]).push({ - click: () => actionHandler(keyboardAction.action), - label: keyboardAction.label, - accelerator: keyboardAction.accelerator, - enabled: false, - }); - } -} - export function activateMenuItems( activePlugin: | FlipperPlugin @@ -156,57 +115,6 @@ export function activateMenuItems( ); } -export function addSandyPluginEntries(entries: NormalizedMenuEntry[]) { - if (!electron.remote.Menu) { - return; - } - - // disable all keyboard actions - for (const item of menuItems) { - item[1].enabled = false; - } - - pluginActionHandler = (action: string) => { - entries.find((e) => e.action === action)?.handler(); - }; - - let changedItems = false; - const currentMenu = electron.remote.Menu.getApplicationMenu(); - for (const entry of entries) { - const item = menuItems.get(entry.action!); - if (item) { - item.enabled = true; - item.accelerator = entry.accelerator; - } else { - const parent = currentMenu?.items.find( - (i) => i.label === entry.topLevelMenu, - ); - if (parent) { - const item = new electron.remote.MenuItem({ - enabled: true, - click: _wrapInteractionHandler( - () => pluginActionHandler?.(entry.action!), - 'MenuItem', - 'onClick', - 'flipper:menu:' + entry.topLevelMenu, - entry.label, - ), - label: entry.label, - accelerator: entry.accelerator, - }); - parent.submenu!.append(item); - menuItems.set(entry.action!, item); - changedItems = true; - } else { - console.warn('Invalid top level menu: ' + entry.topLevelMenu); - } - } - } - if (changedItems) { - electron.remote.Menu.setApplicationMenu(currentMenu); - } -} - function trackMenuItems(menu: string, items: MenuItemConstructorOptions[]) { items.forEach((item) => { if (item.label && item.click) { diff --git a/desktop/app/src/RenderHost.tsx b/desktop/app/src/RenderHost.tsx index c503fe3bd..8a763ade3 100644 --- a/desktop/app/src/RenderHost.tsx +++ b/desktop/app/src/RenderHost.tsx @@ -59,7 +59,11 @@ export interface RenderHost { showSaveDialog?: FlipperLib['showSaveDialog']; showOpenDialog?: FlipperLib['showOpenDialog']; showSelectDirectoryDialog?(defaultPath?: string): Promise; - registerShortcut(shortCut: string, callback: () => void): void; + /** + * @returns + * A callback to unregister the shortcut + */ + registerShortcut(shortCut: string, callback: () => void): () => void; hasFocus(): boolean; onIpcEvent( event: Event, @@ -96,7 +100,9 @@ if (process.env.NODE_ENV === 'test') { return ''; }, writeTextToClipboard() {}, - registerShortcut() {}, + registerShortcut() { + return () => undefined; + }, hasFocus() { return true; }, diff --git a/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap b/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap index 8c3cd2c7f..4c51eb724 100644 --- a/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap +++ b/desktop/app/src/__tests__/__snapshots__/createMockFlipperWithPlugin.node.tsx.snap @@ -41,6 +41,7 @@ Object { "off": [MockFunction], "on": [MockFunction], }, + "pluginMenuEntries": Array [], "selectedAppId": "TestApp#Android#MockAndroidDevice#serial", "selectedAppPluginListRevision": 0, "selectedDevice": Object { diff --git a/desktop/app/src/chrome/PluginActionsMenu.tsx b/desktop/app/src/chrome/PluginActionsMenu.tsx new file mode 100644 index 000000000..739ea5a26 --- /dev/null +++ b/desktop/app/src/chrome/PluginActionsMenu.tsx @@ -0,0 +1,144 @@ +/** + * 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 Icon from '@ant-design/icons'; +import {css} from '@emotion/css'; +import {Button, Menu, MenuItemProps} from 'antd'; +import { + NormalizedMenuEntry, + TrackingScope, + useTrackedCallback, +} from 'flipper-plugin'; +import React, {useEffect} from 'react'; +import {getRenderHostInstance} from '../RenderHost'; +import {getActivePlugin} from '../selectors/connections'; +import {useStore} from '../utils/useStore'; + +function MagicIcon() { + return ( + // https://www.svgrepo.com/svg/59702/magic + + Magic + + + + + + + ); +} +const menu = css` + border: none; +`; +const submenu = css` + .ant-menu-submenu-title { + width: 32px; + height: 32px !important; + line-height: 32px !important; + padding: 0; + margin: 0; + } + .ant-menu-submenu-arrow { + display: none; + } +`; + +function PluginActionMenuItem({ + label, + action, + handler, + accelerator, + // Some props like `eventKey` are auto-generated by ant-design + // We need to pass them through to MenuItem + ...antdProps +}: NormalizedMenuEntry & MenuItemProps) { + const trackedHandler = useTrackedCallback(action, handler, [action, handler]); + + useEffect(() => { + if (accelerator) { + const unregister = getRenderHostInstance().registerShortcut( + accelerator, + trackedHandler, + ); + return unregister; + } + }, [trackedHandler, accelerator]); + + return ( + + {label} + + ); +} +export function PluginActionsMenu() { + const menuEntries = useStore((state) => state.connections.pluginMenuEntries); + const activePlugin = useStore(getActivePlugin); + + if (!menuEntries.length || !activePlugin) { + return null; + } + + return ( + + + } + title="Plugin actions" + type="ghost" + /> + } + className={submenu}> + {menuEntries.map((entry) => ( + + ))} + + + + ); +} diff --git a/desktop/app/src/electron/initializeElectron.tsx b/desktop/app/src/electron/initializeElectron.tsx index 6ac7b2708..d7ac07bc5 100644 --- a/desktop/app/src/electron/initializeElectron.tsx +++ b/desktop/app/src/electron/initializeElectron.tsx @@ -71,6 +71,7 @@ export function initializeElectron() { }, registerShortcut(shortcut, callback) { remote.globalShortcut.register(shortcut, callback); + return () => remote.globalShortcut.unregister(shortcut); }, hasFocus() { return remote.getCurrentWindow().isFocused(); diff --git a/desktop/app/src/reducers/connections.tsx b/desktop/app/src/reducers/connections.tsx index 0c385b797..9b6f2c997 100644 --- a/desktop/app/src/reducers/connections.tsx +++ b/desktop/app/src/reducers/connections.tsx @@ -27,6 +27,7 @@ import {getPluginKey} from '../utils/pluginKey'; import {deconstructClientId} from 'flipper-common'; import type {RegisterPluginAction} from './plugins'; import {shallowEqual} from 'react-redux'; +import {NormalizedMenuEntry} from 'flipper-plugin'; export type StaticViewProps = {logger: Logger}; @@ -68,6 +69,7 @@ type StateV2 = { selectedDevice: null | BaseDevice; selectedPlugin: null | string; selectedAppId: null | string; // Full quantified identifier of the app + pluginMenuEntries: NormalizedMenuEntry[]; userPreferredDevice: null | string; userPreferredPlugin: null | string; userPreferredApp: null | string; // The name of the preferred app, e.g. Facebook @@ -110,6 +112,10 @@ export type Action = time: number; }; } + | { + type: 'SET_MENU_ENTRIES'; + payload: NormalizedMenuEntry[]; + } | { type: 'NEW_CLIENT'; payload: Client; @@ -173,6 +179,7 @@ const INITAL_STATE: State = { selectedDevice: null, selectedAppId: null, selectedPlugin: DEFAULT_PLUGIN, + pluginMenuEntries: [], userPreferredDevice: null, userPreferredPlugin: null, userPreferredApp: null, @@ -230,6 +237,10 @@ export default (state: State = INITAL_STATE, action: Actions): State => { }; } + case 'SET_MENU_ENTRIES': { + return {...state, pluginMenuEntries: action.payload}; + } + case 'REGISTER_DEVICE': { const {payload} = action; @@ -479,6 +490,11 @@ export const selectPlugin = (payload: { payload: {...payload, time: payload.time ?? Date.now()}, }); +export const setMenuEntries = (menuEntries: NormalizedMenuEntry[]): Action => ({ + type: 'SET_MENU_ENTRIES', + payload: menuEntries, +}); + export const selectClient = (clientId: string): Action => ({ type: 'SELECT_CLIENT', payload: clientId, diff --git a/desktop/app/src/sandy-chrome/LeftRail.tsx b/desktop/app/src/sandy-chrome/LeftRail.tsx index 448363f56..b90fefca4 100644 --- a/desktop/app/src/sandy-chrome/LeftRail.tsx +++ b/desktop/app/src/sandy-chrome/LeftRail.tsx @@ -188,9 +188,9 @@ export const LeftRail = withTrackingScope(function LeftRail({ + - {config.showLogin && } @@ -233,7 +233,7 @@ function ImportExportButton() { ); return ( - + +
+ )} diff --git a/desktop/app/src/utils/createSandyPluginWrapper.tsx b/desktop/app/src/utils/createSandyPluginWrapper.tsx index 02a9c5213..7652d5a28 100644 --- a/desktop/app/src/utils/createSandyPluginWrapper.tsx +++ b/desktop/app/src/utils/createSandyPluginWrapper.tsx @@ -115,11 +115,10 @@ export function createSandyPluginWrapper( }, }; } else { - const {action, label, accelerator, topLevelMenu} = def; + const {action, label, accelerator} = def; return { label, accelerator, - topLevelMenu, handler() { executeKeyboardAction(action); }, diff --git a/desktop/app/src/utils/flipperLibImplementation.tsx b/desktop/app/src/utils/flipperLibImplementation.tsx index 6ee718565..fe28f02b7 100644 --- a/desktop/app/src/utils/flipperLibImplementation.tsx +++ b/desktop/app/src/utils/flipperLibImplementation.tsx @@ -18,19 +18,18 @@ import {addNotification} from '../reducers/notifications'; import {deconstructPluginKey} from 'flipper-common'; import {DetailSidebarImpl} from '../sandy-chrome/DetailSidebarImpl'; import {RenderHost} from '../RenderHost'; +import {setMenuEntries} from '../reducers/connections'; export function initializeFlipperLibImplementation( renderHost: RenderHost, store: Store, logger: Logger, ) { - // late require to avoid cyclic dependency - const {addSandyPluginEntries} = require('../MenuBar'); _setFlipperLibImplementation({ isFB: !constants.IS_PUBLIC_BUILD, logger, enableMenuEntries(entries) { - addSandyPluginEntries(entries); + store.dispatch(setMenuEntries(entries)); }, createPaste, GK(gatekeeper: string) { diff --git a/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx b/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx index 0866f7b8d..f45d163ca 100644 --- a/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx +++ b/desktop/flipper-plugin/src/__tests__/test-utils.node.tsx @@ -467,7 +467,6 @@ test('plugins can register menu entries', async () => { }, { label: 'Custom Action', - topLevelMenu: 'Edit', handler() { counter.set(counter.get() + 3); }, diff --git a/desktop/flipper-plugin/src/plugin/MenuEntry.tsx b/desktop/flipper-plugin/src/plugin/MenuEntry.tsx index 00bfaad0f..f1958e59d 100644 --- a/desktop/flipper-plugin/src/plugin/MenuEntry.tsx +++ b/desktop/flipper-plugin/src/plugin/MenuEntry.tsx @@ -7,15 +7,12 @@ * @format */ -export type TopLevelMenu = 'Edit' | 'View' | 'Window' | 'Help'; - export type MenuEntry = BuiltInMenuEntry | CustomMenuEntry; export type DefaultKeyboardAction = keyof typeof buildInMenuEntries; export type NormalizedMenuEntry = { label: string; accelerator?: string; - topLevelMenu: TopLevelMenu; handler: () => void; action: string; }; @@ -23,7 +20,6 @@ export type NormalizedMenuEntry = { export type CustomMenuEntry = { label: string; accelerator?: string; - topLevelMenu: TopLevelMenu; handler: () => void; }; @@ -36,18 +32,15 @@ export const buildInMenuEntries = { clear: { label: 'Clear', accelerator: 'CmdOrCtrl+K', - topLevelMenu: 'View', action: 'clear', }, goToBottom: { label: 'Go To Bottom', accelerator: 'CmdOrCtrl+B', - topLevelMenu: 'View', action: 'goToBottom', }, createPaste: { label: 'Create Paste', - topLevelMenu: 'Edit', action: 'createPaste', }, } as const; diff --git a/desktop/plugins/public/seamammals/src/index_custom.tsx b/desktop/plugins/public/seamammals/src/index_custom.tsx index 42101ead8..190ec3cb4 100644 --- a/desktop/plugins/public/seamammals/src/index_custom.tsx +++ b/desktop/plugins/public/seamammals/src/index_custom.tsx @@ -38,7 +38,6 @@ export function plugin(client: PluginClient) { client.addMenuEntry( { label: 'Reset Selection', - topLevelMenu: 'Edit', handler: () => { selectedID.set(null); }, diff --git a/docs/extending/flipper-plugin.mdx b/docs/extending/flipper-plugin.mdx index 36cf3d569..a2a9eac87 100644 --- a/docs/extending/flipper-plugin.mdx +++ b/docs/extending/flipper-plugin.mdx @@ -250,7 +250,6 @@ Example: ```typescript client.addMenuEntry({ label: 'Reset Selection', - topLevelMenu: 'Edit', accelerator: 'CmdOrCtrl+R' handler: () => { // Event handling @@ -258,9 +257,9 @@ client.addMenuEntry({ } ``` -The `accelerator` argument is optional, but describes the keyboard shortcut. See the [Electron docs](https://www.electronjs.org/docs/api/accelerator) for their format. The `topLevelMenu` must be one of `"Edit"`, `"View"`, `"Window"` or `"Help"`. +The `accelerator` argument is optional, but describes the keyboard shortcut. See the [Electron docs](https://www.electronjs.org/docs/api/accelerator) for their format. -It is possible to leave out the `label`, `topLevelMenu` and `accelerator` fields if a pre-defined `action` is set, which configures all three of them. +It is possible to leave out the `label`, and `accelerator` fields if a pre-defined `action` is set, which configures all three of them. The currently pre-defined actions are `"Clear"`, `"Go To Bottom"` and `"Create Paste"`. Example of using a pre-defined action: