text: // Copyright 2017 The Chromium Authors. All rights reserved.
  // Use of this source code is governed by a BSD-style license that can be
  // found in the LICENSE file.
  
  /**
   * @fileoverview Element which shows context menus and handles keyboard
   * shortcuts.
   */
  import 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.m.js';
  import 'chrome://resources/cr_elements/cr_button/cr_button.m.js';
  import 'chrome://resources/cr_elements/cr_lazy_render/cr_lazy_render.m.js';
  import 'chrome://resources/cr_elements/shared_vars_css.m.js';
  import 'chrome://resources/polymer/v3_0/iron-a11y-keys-behavior/iron-a11y-keys-behavior.js';
  import './edit_dialog.js';
  import './shared_style.js';
  import './strings.m.js';
  
  import {getToastManager} from 'chrome://resources/cr_elements/cr_toast/cr_toast_manager.m.js';
  import {assert} from 'chrome://resources/js/assert.m.js';
  import {isMac} from 'chrome://resources/js/cr.m.js';
  import {KeyboardShortcutList} from 'chrome://resources/js/cr/ui/keyboard_shortcut_list.m.js';
  import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
  import {IronA11yAnnouncer} from 'chrome://resources/polymer/v3_0/iron-a11y-announcer/iron-a11y-announcer.js';
  import {afterNextRender, flush, html, Polymer} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
  
  import {deselectItems, selectAll, selectFolder} from './actions.js';
  import {highlightUpdatedItems, trackUpdatedItems} from './api_listener.js';
  import {BrowserProxy} from './browser_proxy.js';
  import {Command, IncognitoAvailability, MenuSource, OPEN_CONFIRMATION_LIMIT, ROOT_NODE_ID} from './constants.js';
  import {DialogFocusManager} from './dialog_focus_manager.js';
  import {StoreClient} from './store_client.js';
  import {BookmarkNode} from './types.js';
  import {canEditNode, canReorderChildren, getDisplayedList} from './util.js';
  
  export const CommandManager = Polymer({
    is: 'bookmarks-command-manager',
  
    _template: html`{__html_template__}`,
  
    behaviors: [
      StoreClient,
    ],
  
    properties: {
      /** @private {!Array<Command>} */
      menuCommands_: {
        type: Array,
        computed: 'computeMenuCommands_(menuSource_)',
      },
  
      /** @private {Set<string>} */
      menuIds_: Object,
  
      /** @private */
      hasAnySublabel_: {
        type: Boolean,
        reflectToAttribute: true,
        computed: 'computeHasAnySublabel_(menuCommands_, menuIds_)',
      },
  
      /**
       * Indicates where the context menu was opened from. Will be NONE if
       * menu is not open, indicating that commands are from keyboard shortcuts
       * or elsewhere in the UI.
       * @private {MenuSource}
       */
      menuSource_: {
        type: Number,
        value: MenuSource.NONE,
      },
  
      /** @private */
      canPaste_: Boolean,
  
      /** @private */
      globalCanEdit_: Boolean,
    },
  
    /** @private {?Function} */
    confirmOpenCallback_: null,
  
    attached() {
      assert(CommandManager.instance_ === null);
      CommandManager.instance_ = this;
  
      /** @private {!BrowserProxy} */
      this.browserProxy_ = BrowserProxy.getInstance();
  
      this.watch('globalCanEdit_', state => state.prefs.canEdit);
      this.updateFromStore();
  
      /** @private {!Map<Command, KeyboardShortcutList>} */
      this.shortcuts_ = new Map();
  
      this.addShortcut_(Command.EDIT, 'F2', 'Enter');
      this.addShortcut_(Command.DELETE, 'Delete', 'Delete Backspace');
  
      this.addShortcut_(Command.OPEN, 'Enter', 'Meta|o');
      this.addShortcut_(Command.OPEN_NEW_TAB, 'Ctrl|Enter', 'Meta|Enter');
      this.addShortcut_(Command.OPEN_NEW_WINDOW, 'Shift|Enter');
  
      // Note: the undo shortcut is also defined in bookmarks_ui.cc
      // TODO(b/893033): de-duplicate shortcut by moving all shortcut
      // definitions from JS to C++.
      this.addShortcut_(Command.UNDO, 'Ctrl|z', 'Meta|z');
      this.addShortcut_(Command.REDO, 'Ctrl|y Ctrl|Shift|Z', 'Meta|Shift|Z');
  
      this.addShortcut_(Command.SELECT_ALL, 'Ctrl|a', 'Meta|a');
      this.addShortcut_(Command.DESELECT_ALL, 'Escape');
  
      this.addShortcut_(Command.CUT, 'Ctrl|x', 'Meta|x');
      this.addShortcut_(Command.COPY, 'Ctrl|c', 'Meta|c');
      this.addShortcut_(Command.PASTE, 'Ctrl|v', 'Meta|v');
  
      /** @private {!Map<string, Function>} */
      this.boundListeners_ = new Map();
  
      const addDocumentListener = (eventName, handler) => {
        assert(!this.boundListeners_.has(eventName));
        const boundListener = handler.bind(this);
        this.boundListeners_.set(eventName, boundListener);
        document.addEventListener(eventName, boundListener);
      };
      addDocumentListener('open-command-menu', this.onOpenCommandMenu_);
      addDocumentListener('keydown', this.onKeydown_);
  
      const addDocumentListenerForCommand = (eventName, command) => {
        addDocumentListener(eventName, (e) => {
          if (e.path[0].tagName === 'INPUT') {
            return;
          }
  
          const items = this.getState().selection.items;
          if (this.canExecute(command, items)) {
            this.handle(command, items);
          }
        });
      };
      addDocumentListenerForCommand('command-undo', Command.UNDO);
      addDocumentListenerForCommand('cut', Command.CUT);
      addDocumentListenerForCommand('copy', Command.COPY);
      addDocumentListenerForCommand('paste', Command.PASTE);
  
      afterNextRender(this, function() {
        IronA11yAnnouncer.requestAvailability();
      });
    },
  
    detached() {
      CommandManager.instance_ = null;
      this.boundListeners_.forEach(
          (handler, eventName) =>
              document.removeEventListener(eventName, handler));
    },
  
    /**
     * Display the command context menu at (|x|, |y|) in window coordinates.
     * Commands will execute on |items| if given, or on the currently selected
     * items.
     * @param {number} x
     * @param {number} y
     * @param {MenuSource} source
     * @param {Set<string>=} items
     */
    openCommandMenuAtPosition(x, y, source, items) {
      this.menuSource_ = source;
      this.menuIds_ = items || this.getState().selection.items;
  
      const dropdown =
          /** @type {!CrActionMenuElement} */ (this.$.dropdown.get());
      // Ensure that the menu is fully rendered before trying to position it.
      flush();
      DialogFocusManager.getInstance().showDialog(
          dropdown.getDialog(), function() {
            dropdown.showAtPosition({top: y, left: x});
          });
    },
  
    /**
     * Display the command context menu positioned to cover the |target|
     * element. Commands will execute on the currently selected items.
     * @param {!Element} target
     * @param {MenuSource} source
     */
    openCommandMenuAtElement(target, source) {
      this.menuSource_ = source;
      this.menuIds_ = this.getState().selection.items;
  
      const dropdown =
          /** @type {!CrActionMenuElement} */ (this.$.dropdown.get());
      // Ensure that the menu is fully rendered before trying to position it.
      flush();
      DialogFocusManager.getInstance().showDialog(
          dropdown.getDialog(), function() {
            dropdown.showAt(target);
          });
    },
  
    closeCommandMenu() {
      this.menuIds_ = new Set();
      this.menuSource_ = MenuSource.NONE;
      /** @type {!CrActionMenuElement} */ (this.$.dropdown.get()).close();
    },
  
    ////////////////////////////////////////////////////////////////////////////
    // Command handlers:
  
    /**
     * Determine if the |command| can be executed with the given |itemIds|.
     * Commands which appear in the context menu should be implemented
     * separately using `isCommandVisible_` and `isCommandEnabled_`.
     * @param {Command} command
     * @param {!Set<string>} itemIds
     * @return {boolean}
     */
    canExecute(command, itemIds) {
      const state = this.getState();
      switch (command) {
        case Command.OPEN:
          return itemIds.size > 0;
        case Command.UNDO:
        case Command.REDO:
          return this.globalCanEdit_;
        case Command.SELECT_ALL:
        case Command.DESELECT_ALL:
          return true;
        case Command.COPY:
          return itemIds.size > 0;
        case Command.CUT:
          return itemIds.size > 0 &&
              !this.containsMatchingNode_(itemIds, function(node) {
                return !canEditNode(state, node.id);
              });
        case Command.PASTE:
          return state.search.term === '' &&
              canReorderChildren(state, state.selectedFolder);
        default:
          return this.isCommandVisible_(command, itemIds) &&
              this.isCommandEnabled_(command, itemIds);
      }
    },
  
    /**
     * @param {Command} command
     * @param {!Set<string>} itemIds
     * @return {boolean} True if the command should be visible in the context
     *     menu.
     */
    isCommandVisible_(command, itemIds) {
      switch (command) {
        case Command.EDIT:
          return itemIds.size === 1 && this.globalCanEdit_;
        case Command.PASTE:
          return this.globalCanEdit_;
        case Command.CUT:
        case Command.COPY:
          return itemIds.size >= 1 && this.globalCanEdit_;
        case Command.COPY_URL:
          return this.isSingleBookmark_(itemIds);
        case Command.DELETE:
          return itemIds.size > 0 && this.globalCanEdit_;
        case Command.SHOW_IN_FOLDER:
          return this.menuSource_ === MenuSource.ITEM && itemIds.size === 1 &&
              this.getState().search.term !== '' &&
              !this.containsMatchingNode_(itemIds, function(node) {
                return !node.parentId || node.parentId === ROOT_NODE_ID;
              });
        case Command.OPEN_NEW_TAB:
        case Command.OPEN_NEW_WINDOW:
        case Command.OPEN_INCOGNITO:
          return itemIds.size > 0;
        case Command.ADD_BOOKMARK:
        case Command.ADD_FOLDER:
        case Command.SORT:
        case Command.EXPORT:
        case Command.IMPORT:
        case Command.HELP_CENTER:
          return true;
      }
      return assert(false);
    },
  
    /**
     * @param {Command} command
     * @param {!Set<string>} itemIds
     * @return {boolean} True if the command should be clickable in the context
     *     menu.
     */
    isCommandEnabled_(command, itemIds) {
      const state = this.getState();
      switch (command) {
        case Command.EDIT:
        case Command.DELETE:
          return !this.containsMatchingNode_(itemIds, function(node) {
            return !canEditNode(state, node.id);
          });
        case Command.OPEN_NEW_TAB:
        case Command.OPEN_NEW_WINDOW:
          return this.expandUrls_(itemIds).length > 0;
        case Command.OPEN_INCOGNITO:
          return this.expandUrls_(itemIds).length > 0 &&
              state.prefs.incognitoAvailability !==
              IncognitoAvailability.DISABLED;
        case Command.SORT:
          return this.canChangeList_() &&
              state.nodes[state.selectedFolder].children.length > 1;
        case Command.ADD_BOOKMARK:
        case Command.ADD_FOLDER:
          return this.canChangeList_();
        case Command.IMPORT:
          return this.globalCanEdit_;
        case Command.PASTE:
          return this.canPaste_;
        default:
          return true;
      }
    },
  
    /**
     * Returns whether the currently displayed bookmarks list can be changed.
     * @private
     * @return {boolean}
     */
    canChangeList_() {
      const state = this.getState();
      return state.search.term === '' &&
          canReorderChildren(state, state.selectedFolder);
    },
  
    /**
     * @param {Command} command
     * @param {!Set<string>} itemIds
     */
    handle(command, itemIds) {
      const state = this.getState();
      switch (command) {
        case Command.EDIT: {
          const id = Array.from(itemIds)[0];
          /** @type {!BookmarksEditDialogElement} */ (this.$.editDialog.get())
              .showEditDialog(state.nodes[id]);
          break;
        }
        case Command.COPY_URL:
        case Command.COPY: {
          const idList = Array.from(itemIds);
          chrome.bookmarkManagerPrivate.copy(idList, () => {
            let labelPromise;
            if (command === Command.COPY_URL) {
              labelPromise =
                  Promise.resolve(loadTimeData.getString('toastUrlCopied'));
            } else if (idList.length === 1) {
              labelPromise =
                  Promise.resolve(loadTimeData.getString('toastItemCopied'));
            } else {
              labelPromise = this.browserProxy_.getPluralString(
                  'toastItemsCopied', idList.length);
            }
  
            this.showTitleToast_(
                labelPromise, state.nodes[idList[0]].title, false);
          });
          break;
        }
        case Command.SHOW_IN_FOLDER: {
          const id = Array.from(itemIds)[0];
          this.dispatch(
              selectFolder(assert(state.nodes[id].parentId), state.nodes));
          DialogFocusManager.getInstance().clearFocus();
          this.fire('highlight-items', [id]);
          break;
        }
        case Command.DELETE: {
          const idList = Array.from(this.minimizeDeletionSet_(itemIds));
          const title = state.nodes[idList[0]].title;
          let labelPromise;
  
          if (idList.length === 1) {
            labelPromise =
                Promise.resolve(loadTimeData.getString('toastItemDeleted'));
          } else {
            labelPromise = this.browserProxy_.getPluralString(
                'toastItemsDeleted', idList.length);
          }
  
          chrome.bookmarkManagerPrivate.removeTrees(idList, () => {
            this.showTitleToast_(labelPromise, title, true);
          });
          break;
        }
        case Command.UNDO:
          chrome.bookmarkManagerPrivate.undo();
          getToastManager().hide();
          break;
        case Command.REDO:
          chrome.bookmarkManagerPrivate.redo();
          break;
        case Command.OPEN_NEW_TAB:
        case Command.OPEN_NEW_WINDOW:
        case Command.OPEN_INCOGNITO:
          this.openUrls_(this.expandUrls_(itemIds), command);
          break;
        case Command.OPEN:
          if (this.isFolder_(itemIds)) {
            const folderId = Array.from(itemIds)[0];
            this.dispatch(selectFolder(folderId, state.nodes));
          } else {
            this.openUrls_(this.expandUrls_(itemIds), command);
          }
          break;
        case Command.SELECT_ALL:
          const displayedIds = getDisplayedList(state);
          this.dispatch(selectAll(displayedIds, state));
          break;
        case Command.DESELECT_ALL:
          this.dispatch(deselectItems());
          break;
        case Command.CUT:
          chrome.bookmarkManagerPrivate.cut(Array.from(itemIds));
          break;
        case Command.PASTE:
          const selectedFolder = state.selectedFolder;
          const selectedItems = state.selection.items;
          trackUpdatedItems();
          chrome.bookmarkManagerPrivate.paste(
              selectedFolder, Array.from(selectedItems), highlightUpdatedItems);
          break;
        case Command.SORT:
          chrome.bookmarkManagerPrivate.sortChildren(
              assert(state.selectedFolder));
          getToastManager().querySelector('dom-if').if = true;
          getToastManager().show(loadTimeData.getString('toastFolderSorted'));
          this.fire('iron-announce', {
            text: loadTimeData.getString('undoDescription'),
          });
          break;
        case Command.ADD_BOOKMARK:
          /** @type {!BookmarksEditDialogElement} */ (this.$.editDialog.get())
              .showAddDialog(false, assert(state.selectedFolder));
          break;
        case Command.ADD_FOLDER:
          /** @type {!BookmarksEditDialogElement} */ (this.$.editDialog.get())
              .showAddDialog(true, assert(state.selectedFolder));
          break;
        case Command.IMPORT:
          chrome.bookmarks.import();
          break;
        case Command.EXPORT:
          chrome.bookmarks.export();
          break;
        case Command.HELP_CENTER:
          window.open('https://support.google.com/chrome/?p=bookmarks');
          break;
        default:
          assert(false);
      }
      this.recordCommandHistogram_(
          itemIds, 'BookmarkManager.CommandExecuted', command);
    },
  
    /**
     * @param {!Event} e
     * @param {!Set<string>} itemIds
     * @return {boolean} True if the event was handled, triggering a keyboard
     *     shortcut.
     */
    handleKeyEvent(e, itemIds) {
      for (const commandTuple of this.shortcuts_) {
        const command = /** @type {Command} */ (commandTuple[0]);
        const shortcut =
            /** @type {KeyboardShortcutList} */ (commandTuple[1]);
        if (shortcut.matchesEvent(e) && this.canExecute(command, itemIds)) {
          this.handle(command, itemIds);
  
          this.recordCommandHistogram_(
              itemIds, 'BookmarkManager.CommandExecutedFromKeyboard', command);
          e.stopPropagation();
          e.preventDefault();
          return true;
        }
      }
  
      return false;
    },
  
    ////////////////////////////////////////////////////////////////////////////
    // Private functions:
  
    /**
     * Register a keyboard shortcut for a command.
     * @param {Command} command Command that the shortcut will trigger.
     * @param {string} shortcut Keyboard shortcut, using the syntax of
     *     cr/ui/command.js.
     * @param {string=} macShortcut If set, enables a replacement shortcut for
     *     Mac.
     */
    addShortcut_(command, shortcut, macShortcut) {
      shortcut = (isMac && macShortcut) ? macShortcut : shortcut;
      this.shortcuts_.set(command, new KeyboardShortcutList(shortcut));
    },
  
    /**
     * Minimize the set of |itemIds| by removing any node which has an ancestor
     * node already in the set. This ensures that instead of trying to delete
     * both a node and its descendant, we will only try to delete the topmost
     * node, preventing an error in the bookmarkManagerPrivate.removeTrees API
     * call.
     * @param {!Set<string>} itemIds
     * @return {!Set<string>}
     */
    minimizeDeletionSet_(itemIds) {
      const minimizedSet = new Set();
      const nodes = this.getState().nodes;
      itemIds.forEach(function(itemId) {
        let currentId = itemId;
        while (currentId !== ROOT_NODE_ID) {
          currentId = assert(nodes[currentId].parentId);
          if (itemIds.has(currentId)) {
            return;
          }
        }
        minimizedSet.add(itemId);
      });
      return minimizedSet;
    },
  
    /**
     * Open the given |urls| in response to a |command|. May show a confirmation
     * dialog before opening large numbers of URLs.
     * @param {!Array<string>} urls
     * @param {Command} command
     * @private
     */
    openUrls_(urls, command) {
      assert(
          command === Command.OPEN || command === Command.OPEN_NEW_TAB ||
          command === Command.OPEN_NEW_WINDOW ||
          command === Command.OPEN_INCOGNITO);
  
      if (urls.length === 0) {
        return;
      }
  
      const openUrlsCallback = function() {
        const incognito = command === Command.OPEN_INCOGNITO;
        if (command === Command.OPEN_NEW_WINDOW || incognito) {
          chrome.windows.create({url: urls, incognito: incognito});
        } else {
          if (command === Command.OPEN) {
            chrome.tabs.create({url: urls.shift(), active: true});
          }
          urls.forEach(function(url) {
            chrome.tabs.create({url: url, active: false});
          });
        }
      };
  
      if (urls.length <= OPEN_CONFIRMATION_LIMIT) {
        openUrlsCallback();
        return;
      }
  
      this.confirmOpenCallback_ = openUrlsCallback;
      const dialog = this.$.openDialog.get();
      dialog.querySelector('[slot=body]').textContent =
          loadTimeData.getStringF('openDialogBody', urls.length);
  
      DialogFocusManager.getInstance().showDialog(this.$.openDialog.get());
    },
  
    /**
     * Returns all URLs in the given set of nodes and their immediate children.
     * Note that these will be ordered by insertion order into the |itemIds|
     * set, and that it is possible to duplicate a URL by passing in both the
     * parent ID and child ID.
     * @param {!Set<string>} itemIds
     * @return {!Array<string>}
     * @private
     */
    expandUrls_(itemIds) {
      const urls = [];
      const nodes = this.getState().nodes;
  
      itemIds.forEach(function(id) {
        const node = nodes[id];
        if (node.url) {
          urls.push(node.url);
        } else {
          node.children.forEach(function(childId) {
            const childNode = nodes[childId];
            if (childNode.url) {
              urls.push(childNode.url);
            }
          });
        }
      });
  
      return urls;
    },
  
    /**
     * @param {!Set<string>} itemIds
     * @param {function(BookmarkNode):boolean} predicate
     * @return {boolean} True if any node in |itemIds| returns true for
     *     |predicate|.
     */
    containsMatchingNode_(itemIds, predicate) {
      const nodes = this.getState().nodes;
  
      return Array.from(itemIds).some(function(id) {
        return predicate(nodes[id]);
      });
    },
  
    /**
     * @param {!Set<string>} itemIds
     * @return {boolean} True if |itemIds| is a single bookmark (non-folder)
     *     node.
     * @private
     */
    isSingleBookmark_(itemIds) {
      return itemIds.size === 1 &&
          this.containsMatchingNode_(itemIds, function(node) {
            return !!node.url;
          });
    },
  
    /**
     * @param {!Set<string>} itemIds
     * @return {boolean}
     * @private
     */
    isFolder_(itemIds) {
      return itemIds.size === 1 &&
          this.containsMatchingNode_(itemIds, node => !node.url);
    },
  
    /**
     * @param {Command} command
     * @return {string}
     * @private
     */
    getCommandLabel_(command) {
      const multipleNodes = this.menuIds_.size > 1 ||
          this.containsMatchingNode_(this.menuIds_, function(node) {
            return !node.url;
          });
      let label;
      switch (command) {
        case Command.EDIT:
          if (this.menuIds_.size !== 1) {
            return '';
          }
  
          const id = Array.from(this.menuIds_)[0];
          const itemUrl = this.getState().nodes[id].url;
          label = itemUrl ? 'menuEdit' : 'menuRename';
          break;
        case Command.CUT:
          label = 'menuCut';
          break;
        case Command.COPY:
          label = 'menuCopy';
          break;
        case Command.COPY_URL:
          label = 'menuCopyURL';
          break;
        case Command.PASTE:
          label = 'menuPaste';
          break;
        case Command.DELETE:
          label = 'menuDelete';
          break;
        case Command.SHOW_IN_FOLDER:
          label = 'menuShowInFolder';
          break;
        case Command.OPEN_NEW_TAB:
          label = multipleNodes ? 'menuOpenAllNewTab' : 'menuOpenNewTab';
          break;
        case Command.OPEN_NEW_WINDOW:
          label = multipleNodes ? 'menuOpenAllNewWindow' : 'menuOpenNewWindow';
          break;
        case Command.OPEN_INCOGNITO:
          label = multipleNodes ? 'menuOpenAllIncognito' : 'menuOpenIncognito';
          break;
        case Command.SORT:
          label = 'menuSort';
          break;
        case Command.ADD_BOOKMARK:
          label = 'menuAddBookmark';
          break;
        case Command.ADD_FOLDER:
          label = 'menuAddFolder';
          break;
        case Command.IMPORT:
          label = 'menuImport';
          break;
        case Command.EXPORT:
          label = 'menuExport';
          break;
        case Command.HELP_CENTER:
          label = 'menuHelpCenter';
          break;
      }
      assert(label);
  
      return loadTimeData.getString(assert(label));
    },
  
    /**
     * @param {Command} command
     * @return {string}
     * @private
     */
    getCommandSublabel_(command) {
      const multipleNodes = this.menuIds_.size > 1 ||
          this.containsMatchingNode_(this.menuIds_, function(node) {
            return !node.url;
          });
      switch (command) {
        case Command.OPEN_NEW_TAB:
          const urls = this.expandUrls_(this.menuIds_);
          return multipleNodes && urls.length > 0 ? String(urls.length) : '';
        default:
          return '';
      }
    },
  
    /** @private */
    computeMenuCommands_() {
      switch (this.menuSource_) {
        case MenuSource.ITEM:
        case MenuSource.TREE:
          return [
            Command.EDIT,
            Command.SHOW_IN_FOLDER,
            Command.DELETE,
            // <hr>
            Command.CUT,
            Command.COPY,
            Command.COPY_URL,
            Command.PASTE,
            // <hr>
            Command.OPEN_NEW_TAB,
            Command.OPEN_NEW_WINDOW,
            Command.OPEN_INCOGNITO,
          ];
        case MenuSource.TOOLBAR:
          return [
            Command.SORT,
            // <hr>
            Command.ADD_BOOKMARK,
            Command.ADD_FOLDER,
            // <hr>
            Command.IMPORT,
            Command.EXPORT,
            // <hr>
            Command.HELP_CENTER,
          ];
        case MenuSource.LIST:
          return [
            Command.ADD_BOOKMARK,
            Command.ADD_FOLDER,
          ];
        case MenuSource.NONE:
          return [];
      }
      assert(false);
    },
  
    /**
     * @return {boolean}
     * @private
     */
    computeHasAnySublabel_() {
      if (this.menuIds_ === undefined || this.menuCommands_ === undefined) {
        return false;
      }
  
      return this.menuCommands_.some(
          (command) => this.getCommandSublabel_(command) !== '');
    },
  
    /**
     * @param {Command} command
     * @param {!Set<string>} itemIds
     * @return {boolean}
     * @private
     */
    showDividerAfter_(command, itemIds) {
      switch (command) {
        case Command.SORT:
        case Command.ADD_FOLDER:
        case Command.EXPORT:
          return this.menuSource_ === MenuSource.TOOLBAR;
        case Command.DELETE:
          return this.globalCanEdit_;
        case Command.PASTE:
          return this.globalCanEdit_ || this.isSingleBookmark_(itemIds);
      }
      return false;
    },
  
    /**
     * @param {!Set<string>} itemIds
     * @param {string} histogram
     * @param {number} command
     * @private
     */
    recordCommandHistogram_(itemIds, histogram, command) {
      if (command === Command.OPEN) {
        command =
            this.isFolder_(itemIds) ? Command.OPEN_FOLDER : Command.OPEN_BOOKMARK;
      }
  
      this.browserProxy_.recordInHistogram(histogram, command, Command.MAX_VALUE);
    },
  
    /**
     * Show a toast with a bookmark |title| inserted into a label, with the
     * title ellipsised if necessary.
     * @param {!Promise<string>} labelPromise Promise which resolves with the
     *    label for the toast.
     * @param {string} title Bookmark title to insert.
     * @param {boolean} canUndo If true, shows an undo button in the toast.
     * @private
     */
    showTitleToast_: async function(labelPromise, title, canUndo) {
      const label = await labelPromise;
      const pieces =
          loadTimeData.getSubstitutedStringPieces(label, title).map(function(p) {
            // Make the bookmark name collapsible.
            p.collapsible = !!p.arg;
            return p;
          });
      getToastManager().querySelector('dom-if').if = canUndo;
      getToastManager().showForStringPieces(pieces);
      if (canUndo) {
        this.fire('iron-announce', {
          text: loadTimeData.getString('undoDescription'),
        });
      }
    },
  
    /**
     * @param {number} targetId
     * @private
     */
    updateCanPaste_(targetId) {
      return new Promise(resolve => {
        chrome.bookmarkManagerPrivate.canPaste(`${targetId}`, result => {
          this.canPaste_ = result;
          resolve();
        });
      });
    },
  
    ////////////////////////////////////////////////////////////////////////////
    // Event handlers:
  
    /**
     * @param {Event} e
     * @private
     */
    onOpenCommandMenu_: async function(e) {
      await this.updateCanPaste_(e.detail.source);
      if (e.detail.targetElement) {
        this.openCommandMenuAtElement(e.detail.targetElement, e.detail.source);
      } else {
        this.openCommandMenuAtPosition(e.detail.x, e.detail.y, e.detail.source);
      }
      this.browserProxy_.recordInHistogram(
          'BookmarkManager.CommandMenuOpened', e.detail.source,
          MenuSource.NUM_VALUES);
    },
  
    /**
     * @param {Event} e
     * @private
     */
    onCommandClick_(e) {
      this.handle(
          /** @type {Command} */ (
              Number(e.currentTarget.getAttribute('command'))),
          assert(this.menuIds_));
      this.closeCommandMenu();
    },
  
    /**
     * @param {!Event} e
     * @private
     */
    onKeydown_(e) {
      const path = e.composedPath();
      if (path[0].tagName === 'INPUT') {
        return;
      }
      if ((e.target === document.body ||
           path.some(el => el.tagName === 'BOOKMARKS-TOOLBAR')) &&
          !DialogFocusManager.getInstance().hasOpenDialog()) {
        this.handleKeyEvent(e, this.getState().selection.items);
      }
    },
  
    /**
     * Close the menu on mousedown so clicks can propagate to the underlying UI.
     * This allows the user to right click the list while a context menu is
     * showing and get another context menu.
     * @param {Event} e
     * @private
     */
    onMenuMousedown_(e) {
      if (e.path[0].tagName !== 'DIALOG') {
        return;
      }
  
      this.closeCommandMenu();
    },
  
    /** @private */
    onOpenCancelTap_() {
      this.$.openDialog.get().cancel();
    },
  
    /** @private */
    onOpenConfirmTap_() {
      this.confirmOpenCallback_();
      this.$.openDialog.get().close();
    },
  });
  
  /** @private {CommandManager} */
  CommandManager.instance_ = null;
  
  /** @return {!CommandManager} */
  CommandManager.getInstance = function() {
    return assert(CommandManager.instance_);
  };
  