Bug 1254544 - Show clipboard commands in the synced tabs filter context menu. r=markh draft
authorKit Cambridge <kcambridge@mozilla.com>
Wed, 16 Mar 2016 16:31:19 -0700
changeset 342326 086e86f3c7b76ae2c5a757d591a25a73a1d9ddf1
parent 342225 9c5d494d05485aebf3fedf649abc0e7ae9d2dcf2
child 516558 f213c57d2a7e69ae6097c5f54887fde8d6af7701
push id13392
push userkcambridge@mozilla.com
push dateFri, 18 Mar 2016 21:10:22 +0000
reviewersmarkh
bugs1254544
milestone48.0a1
Bug 1254544 - Show clipboard commands in the synced tabs filter context menu. r=markh MozReview-Commit-ID: 9XTYrU0xp9E
browser/base/content/browser.xul
browser/components/syncedtabs/TabListView.js
browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js
--- a/browser/base/content/browser.xul
+++ b/browser/base/content/browser.xul
@@ -467,16 +467,43 @@
       <menuitem label="&syncedTabs.context.bookmarkSingleTab.label;"
                 accesskey="&syncedTabs.context.bookmarkSingleTab.accesskey;"
                 id="syncedTabsBookmarkSelected"/>
       <menuseparator/>
       <menuitem label="&syncSyncNowItem.label;"
                 accesskey="&syncSyncNowItem.accesskey;"
                 id="syncedTabsRefresh"/>
     </menupopup>
+    <menupopup id="SyncedTabsSidebarTabsFilterContext"
+               class="textbox-contextmenu">
+      <menuitem label="&undoCmd.label;"
+                accesskey="&undoCmd.accesskey;"
+                cmd="cmd_undo"/>
+      <menuseparator/>
+      <menuitem label="&cutCmd.label;"
+                accesskey="&cutCmd.accesskey;"
+                cmd="cmd_cut"/>
+      <menuitem label="&copyCmd.label;"
+                accesskey="&copyCmd.accesskey;"
+                cmd="cmd_copy"/>
+      <menuitem label="&pasteCmd.label;"
+                accesskey="&pasteCmd.accesskey;"
+                cmd="cmd_paste"/>
+      <menuitem label="&deleteCmd.label;"
+                accesskey="&deleteCmd.accesskey;"
+                cmd="cmd_delete"/>
+      <menuseparator/>
+      <menuitem label="&selectAllCmd.label;"
+                accesskey="&selectAllCmd.accesskey;"
+                cmd="cmd_selectAll"/>
+      <menuseparator/>
+      <menuitem label="&syncSyncNowItem.label;"
+                accesskey="&syncSyncNowItem.accesskey;"
+                id="syncedTabsRefreshFilter"/>
+    </menupopup>
   </popupset>
 
 #ifdef CAN_DRAW_IN_TITLEBAR
 <vbox id="titlebar">
   <hbox id="titlebar-content">
     <spacer id="titlebar-spacer" flex="1"/>
     <hbox id="titlebar-buttonbox-container">
 #ifdef XP_WIN
--- a/browser/components/syncedtabs/TabListView.js
+++ b/browser/components/syncedtabs/TabListView.js
@@ -16,16 +16,20 @@ let log = Cu.import("resource://gre/modu
 this.EXPORTED_SYMBOLS = [
   "TabListView"
 ];
 
 function getContextMenu(window) {
   return getChromeWindow(window).document.getElementById("SyncedTabsSidebarContext");
 }
 
+function getTabsFilterContextMenu(window) {
+  return getChromeWindow(window).document.getElementById("SyncedTabsSidebarTabsFilterContext");
+}
+
 /*
  * TabListView
  *
  * Given a state, this object will render the corresponding DOM.
  * It maintains no state of it's own. It listens for DOM events
  * and triggers actions that may cause the state to change and
  * ultimately the view to rerender.
  */
@@ -325,56 +329,120 @@ TabListView.prototype = {
     this.props.onFilterFocus();
   },
   onFilterBlur() {
     this.props.onFilterBlur();
   },
 
   // Set up the custom context menu
   _setupContextMenu() {
-    this._handleContentContextMenu = event =>
-        this.handleContentContextMenu(event);
-    this._handleContentContextMenuCommand = event =>
-        this.handleContentContextMenuCommand(event);
-
-    Services.els.addSystemEventListener(this._window, "contextmenu", this._handleContentContextMenu, false);
-    let menu = getContextMenu(this._window);
-    menu.addEventListener("command", this._handleContentContextMenuCommand, true);
+    Services.els.addSystemEventListener(this._window, "contextmenu", this, false);
+    for (let getMenu of [getContextMenu, getTabsFilterContextMenu]) {
+      let menu = getMenu(this._window);
+      menu.addEventListener("popupshowing", this, true);
+      menu.addEventListener("command", this, true);
+    }
   },
 
   _teardownContextMenu() {
     // Tear down context menu
-    Services.els.removeSystemEventListener(this._window, "contextmenu", this._handleContentContextMenu, false);
-    let menu = getContextMenu(this._window);
-    menu.removeEventListener("command", this._handleContentContextMenuCommand, true);
+    Services.els.removeSystemEventListener(this._window, "contextmenu", this, false);
+    for (let getMenu of [getContextMenu, getTabsFilterContextMenu]) {
+      let menu = getMenu(this._window);
+      menu.removeEventListener("popupshowing", this, true);
+      menu.removeEventListener("command", this, true);
+    }
+  },
+
+  handleEvent(event) {
+    switch (event.type) {
+      case "contextmenu":
+        this.handleContextMenu(event);
+        break;
+
+      case "popupshowing": {
+        if (event.target.getAttribute("id") == "SyncedTabsSidebarTabsFilterContext") {
+          this.handleTabsFilterContextMenuShown(event);
+        }
+        break;
+      }
+
+      case "command": {
+        let menu = event.target.closest("menupopup");
+        switch (menu.getAttribute("id")) {
+          case "SyncedTabsSidebarContext":
+            this.handleContentContextMenuCommand(event);
+            break;
+
+          case "SyncedTabsSidebarTabsFilterContext":
+            this.handleTabsFilterContextMenuCommand(event);
+            break;
+        }
+        break;
+      }
+    }
+  },
+
+  handleTabsFilterContextMenuShown(event) {
+    let document = event.target.ownerDocument;
+    let focusedElement = document.commandDispatcher.focusedElement;
+    if (focusedElement != this.tabsFilter) {
+      this.tabsFilter.focus();
+    }
+    for (let item of event.target.children) {
+      if (!item.hasAttribute("cmd")) {
+        continue;
+      }
+      let command = item.getAttribute("cmd");
+      let controller = document.commandDispatcher.getControllerForCommand(command);
+      if (controller.isCommandEnabled(command)) {
+        item.removeAttribute("disabled");
+      } else {
+        item.setAttribute("disabled", "true");
+      }
+    }
   },
 
   handleContentContextMenuCommand(event) {
     let id = event.target.getAttribute("id");
     switch (id) {
       case "syncedTabsOpenSelected":
         this.onOpenSelected(event);
         break;
       case "syncedTabsBookmarkSelected":
         this.onBookmarkTab();
         break;
       case "syncedTabsRefresh":
+      case "syncedTabsRefreshFilter":
         this.props.onSyncRefresh();
         break;
     }
   },
 
-  handleContentContextMenu(event) {
-    let itemNode = this._findParentItemNode(event.target);
-    if (itemNode) {
-      this._selectRow(itemNode);
+  handleTabsFilterContextMenuCommand(event) {
+    let command = event.target.getAttribute("cmd");
+    let dispatcher = getChromeWindow(this._window).document.commandDispatcher;
+    let controller = dispatcher.focusedElement.controllers.getControllerForCommand(command);
+    controller.doCommand(command);
+  },
+
+  handleContextMenu(event) {
+    let menu;
+
+    if (event.target == this.tabsFilter) {
+      menu = getTabsFilterContextMenu(this._window);
+    } else {
+      let itemNode = this._findParentItemNode(event.target);
+      if (itemNode) {
+        this._selectRow(itemNode);
+      }
+      menu = getContextMenu(this._window);
+      this.adjustContextMenu(menu);
     }
 
-    let menu = getContextMenu(this._window);
-    this.adjustContextMenu(menu);
     menu.openPopupAtScreen(event.screenX, event.screenY, true, event);
   },
 
   adjustContextMenu(menu) {
     let item = this.container.querySelector('.item.selected');
     let showTabOptions = this._isTab(item);
 
     let el = menu.firstChild;
--- a/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js
+++ b/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js
@@ -250,16 +250,88 @@ add_task(function* testSyncedTabsSidebar
   yield syncedTabsDeckComponent.updatePanel();
   selectedPanel = syncedTabsDeckComponent.container.querySelector(".sync-state.selected");
   Assert.ok(selectedPanel.classList.contains("tabs-container"),
     "tabs panel is selected");
 });
 
 add_task(testClean);
 
+add_task(function* testSyncedTabsSidebarContextMenu() {
+  yield SidebarUI.show('viewTabsSidebar');
+  let syncedTabsDeckComponent = window.SidebarUI.browser.contentWindow.syncedTabsDeckComponent;
+  let SyncedTabs = window.SidebarUI.browser.contentWindow.SyncedTabs;
+
+  Assert.ok(syncedTabsDeckComponent, "component exists");
+
+  originalSyncedTabsInternal = SyncedTabs._internal;
+  SyncedTabs._internal = {
+    isConfiguredToSyncTabs: true,
+    hasSyncedThisSession: true,
+    getTabClients() { return Promise.resolve([])},
+    syncTabs() {return Promise.resolve();},
+  };
+
+  sinon.stub(syncedTabsDeckComponent, "_accountStatus", ()=> Promise.resolve(true));
+  sinon.stub(SyncedTabs._internal, "getTabClients", ()=> Promise.resolve(Cu.cloneInto(FIXTURE, {})));
+
+  yield syncedTabsDeckComponent.updatePanel();
+  // This is a hacky way of waiting for the view to render. The view renders
+  // after the following promise (a different instance of which is triggered
+  // in updatePanel) resolves, so we wait for it here as well
+  yield syncedTabsDeckComponent.tabListComponent._store.getData();
+
+  info("Right-clicking the search box should show text-related actions");
+  let filterMenuItems = [
+    "menuitem[cmd=cmd_undo]",
+    "menuseparator",
+    // We don't check whether the commands are enabled due to platform
+    // differences. On OS X and Windows, "cut" and "copy" are always enabled
+    // for HTML inputs; on Linux, they're only enabled if text is selected.
+    "menuitem[cmd=cmd_cut]",
+    "menuitem[cmd=cmd_copy]",
+    "menuitem[cmd=cmd_paste]",
+    "menuitem[cmd=cmd_delete]",
+    "menuseparator",
+    "menuitem[cmd=cmd_selectAll]",
+    "menuseparator",
+    "menuitem#syncedTabsRefreshFilter",
+  ];
+  yield* testContextMenu(syncedTabsDeckComponent,
+                         "#SyncedTabsSidebarTabsFilterContext",
+                         ".tabsFilter",
+                         filterMenuItems);
+
+  info("Right-clicking a tab should show additional actions");
+  let tabMenuItems = [
+    ["menuitem#syncedTabsOpenSelected", { hidden: false }],
+    ["menuitem#syncedTabsBookmarkSelected", { hidden: false }],
+    ["menuseparator", { hidden: false }],
+    ["menuitem#syncedTabsRefresh", { hidden: false }],
+  ];
+  yield* testContextMenu(syncedTabsDeckComponent,
+                         "#SyncedTabsSidebarContext",
+                         "#tab-7cqCr77ptzX3-0",
+                         tabMenuItems);
+
+  info("Right-clicking a client shouldn't show any actions");
+  let sidebarMenuItems = [
+    ["menuitem#syncedTabsOpenSelected", { hidden: true }],
+    ["menuitem#syncedTabsBookmarkSelected", { hidden: true }],
+    ["menuseparator", { hidden: true }],
+    ["menuitem#syncedTabsRefresh", { hidden: false }],
+  ];
+  yield* testContextMenu(syncedTabsDeckComponent,
+                         "#SyncedTabsSidebarContext",
+                         "#item-OL3EJCsdb2JD",
+                         sidebarMenuItems);
+});
+
+add_task(testClean);
+
 function checkItem(node, item) {
   Assert.ok(node.classList.contains("item"),
     "Node should have .item class");
   if (item.client) {
     // tab items
     Assert.equal(node.querySelector(".item-title").textContent, item.title,
       "Node's title element's text should match item title");
     Assert.ok(node.classList.contains("tab"),
@@ -274,8 +346,52 @@ function checkItem(node, item) {
       "Node's title element's text should match client name");
     Assert.ok(node.classList.contains("client"),
       "Node should have .client class");
     Assert.equal(node.dataset.id, item.id,
       "Node's ID should match item ID");
   }
 }
 
+function* testContextMenu(syncedTabsDeckComponent, contextSelector, triggerSelector, menuSelectors) {
+  let contextMenu = document.querySelector(contextSelector);
+  let triggerElement = syncedTabsDeckComponent.container.querySelector(triggerSelector);
+
+  let promisePopupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+
+  let chromeWindow = triggerElement.ownerDocument.defaultView.top;
+  let rect = triggerElement.getBoundingClientRect();
+  let contentRect = chromeWindow.SidebarUI.browser.getBoundingClientRect();
+  // The offsets in `rect` are relative to the content window, but
+  // `synthesizeMouseAtPoint` calls `nsIDOMWindowUtils.sendMouseEvent`,
+  // which interprets the offsets relative to the containing *chrome* window.
+  // This means we need to account for the width and height of any elements
+  // outside the `browser` element, like `sidebarheader`.
+  let offsetX = contentRect.x + rect.x + (rect.width / 2);
+  let offsetY = contentRect.y + rect.y + (rect.height / 4);
+
+  yield EventUtils.synthesizeMouseAtPoint(offsetX, offsetY, {
+    type: "contextmenu",
+    button: 2,
+  }, chromeWindow);
+  yield promisePopupShown;
+  checkChildren(contextMenu, menuSelectors);
+
+  let promisePopupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+  contextMenu.hidePopup();
+  yield promisePopupHidden;
+}
+
+function checkChildren(node, selectors) {
+  is(node.children.length, selectors.length, "Menu item count doesn't match");
+  for (let index = 0; index < node.children.length; index++) {
+    let child = node.children[index];
+    let [selector, props] = [].concat(selectors[index]);
+    ok(selector, `Node at ${index} should have selector`);
+    ok(child.matches(selector), `Node ${
+      index} should match ${selector}`);
+    if (props) {
+      Object.keys(props).forEach(prop => {
+        is(child[prop], props[prop], `${prop} value at ${index} should match`);
+      });
+    }
+  }
+}