Bug 1254544 - Show clipboard commands in the synced tabs filter context menu. r=markh
authorKit Cambridge <kcambridge@mozilla.com>
Wed, 16 Mar 2016 16:31:19 -0700
changeset 289609 ba9179915fb0d9cbb3914cdce6f67154d0777074
parent 289608 98189434eea16c181df0c7e8d60af76feceaf492
child 289610 9034f1d7db43f3c4bd8540763752a2e1861b1424
push id30107
push usercbook@mozilla.com
push dateTue, 22 Mar 2016 10:00:23 +0000
treeherdermozilla-central@3587b25bae30 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmarkh
bugs1254544
milestone48.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
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`);
+      });
+    }
+  }
+}