diff --git a/browser/base/content/browser.xul b/browser/base/content/browser.xul index ad84734fd9ac..a1835e93d103 100644 --- a/browser/base/content/browser.xul +++ b/browser/base/content/browser.xul @@ -472,6 +472,33 @@ accesskey="&syncSyncNowItem.accesskey;" id="syncedTabsRefresh"/> + + + + + + + + + + + + #ifdef CAN_DRAW_IN_TITLEBAR diff --git a/browser/components/syncedtabs/TabListView.js b/browser/components/syncedtabs/TabListView.js index b04999d22bcc..846e56e21627 100644 --- a/browser/components/syncedtabs/TabListView.js +++ b/browser/components/syncedtabs/TabListView.js @@ -21,6 +21,10 @@ function getContextMenu(window) { return getChromeWindow(window).document.getElementById("SyncedTabsSidebarContext"); } +function getTabsFilterContextMenu(window) { + return getChromeWindow(window).document.getElementById("SyncedTabsSidebarTabsFilterContext"); +} + /* * TabListView * @@ -330,21 +334,71 @@ TabListView.prototype = { // 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) { @@ -357,19 +411,33 @@ TabListView.prototype = { 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); }, diff --git a/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js b/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js index eebb3d44a61a..00c9d39f4eea 100644 --- a/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js +++ b/browser/components/syncedtabs/test/browser/browser_sidebar_syncedtabslist.js @@ -255,6 +255,78 @@ add_task(function* testSyncedTabsSidebarStatus() { 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"); @@ -279,3 +351,47 @@ function checkItem(node, item) { } } +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`); + }); + } + } +}