# HG changeset patch # User Guido Berhoerster # Date 1543148867 -3600 # Node ID 4704e521641291537accd58727b2a2f5889117ba # Parent 70de81c7c5124c49c7cb61d48d62dab39436c727 Create menus on-the-fly Refactor and eliminate the window tracking code by using the onShown/onHidden events available in Firefox 60 in order to create menu entries on-the-fly. Switch from the Firefox-specific contextMenu to the menu API. diff -r 70de81c7c512 -r 4704e5216412 background.js --- a/background.js Thu May 31 14:07:49 2018 +0200 +++ b/background.js Sun Nov 25 13:27:47 2018 +0100 @@ -1,5 +1,5 @@ /* - * Copyright (C) 2017 Guido Berhoerster + * Copyright (C) 2018 Guido Berhoerster * * This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this @@ -8,397 +8,113 @@ 'use strict'; -function createContextMenuItem(createProperties) { +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'ftp:']); +var windowMenuIds = []; +var lastMenuInstanceId = 0; +var nextMenuInstanceId = 1; + +function createMenuItem(createProperties) { return new Promise((resolve, reject) => { - browser.contextMenus.create(createProperties, () => { + let id = browser.menus.create(createProperties, () => { if (browser.runtime.lastError) { reject(browser.runtime.lastError); } else { - resolve(); + resolve(id); } }); }); } -const Observable = (superclass) => class extends superclass { - constructor(...args) { - super(...args); - - this._observers = new Map(); - } - - addObserver(eventName, observer) { - if (!this._observers.has(eventName)) { - this._observers.set(eventName, new Set()); - } - - this._observers.get(eventName).add(observer); - } +async function moveTab(tab, targetWindowId) { + browser.tabs.move(tab.id, {windowId: targetWindowId, index: -1}); +} - deleteObserver(eventName, observer) { - if (this._observers.has(eventName)) { - this._observers.get(eventName).delete(observer); - } +async function reopenTab(tab, targetWindowId) { + if (!ALLOWED_PROTOCOLS.has(new URL(tab.url).protocol)) { + // privileged tab URL which cannot be reopened + return; } - - notifyObservers(eventName, ...args) { - if (!this._observers.has(eventName)) { - return; - } - - for (let observer of this._observers.get(eventName)) { - observer(eventName, ...args); - } - } + await browser.tabs.create({ + url: tab.url, + windowId: targetWindowId + }); + browser.tabs.remove(tab.id); } -class WindowsModel extends Observable(Object) { - constructor() { - super(); - - this.windows = new Map(); - this.focusedWindowId = browser.windows.WINDOW_ID_NONE; - } - - getWindow(id) { - return this.windows.get(id); - } - - getAllWindows() { - return this.windows.values(); +async function onMenuShown(info, tab) { + let menuInstanceId = nextMenuInstanceId++; + lastMenuInstanceId = menuInstanceId; + let targetWindows = await browser.windows.getAll({ + populate: true, + windowTypes: ['normal'] + }); + let creatingMenus = []; + let moveMenuItems = 0; + let reopenMenuItems = 0; + for (let targetWindow of targetWindows) { + if (targetWindow.id === tab.windowId) { + // ignore active window + continue; + } + if (tab.incognito === targetWindow.incognito) { + creatingMenus.push(createMenuItem({ + onclick: (info, tab) => moveTab(tab, targetWindow.id), + parentId: 'move-menu', + title: targetWindow.title + })); + moveMenuItems++; + } else { + creatingMenus.push(createMenuItem({ + onclick: (info, tab) => reopenTab(tab, targetWindow.id), + parentId: 'reopen-menu', + title: targetWindow.title + })); + reopenMenuItems++; + } } - - getfocusedWindowId() { - return this.focusedWindowId; + let updatingMenus = [ + browser.menus.update('move-menu', {enabled: moveMenuItems > 0}), + browser.menus.update('reopen-menu', {enabled: reopenMenuItems > 0}) + ]; + await Promise.all([...creatingMenus, ...updatingMenus]); + let newWindowMenuIds = await Promise.all(creatingMenus); + if (menuInstanceId !== lastMenuInstanceId) { + // menu has been closed and opened again, remove the items of this + // instance again + for (let menuId of newWindowMenuIds) { + browser.menus.remove(menuId); + } + return; } - - openWindow(id, incognito = false) { - this.windows.set(id, { - id, - title: browser.i18n.getMessage(incognito ? - 'defaultIncognitoWindowTitle' : 'defaultWindowTitle', id), - incognito - }); - - this.notifyObservers('window-opened', id); - } + windowMenuIds = newWindowMenuIds; + browser.menus.refresh(); +} - updateWindowTitle(id, title) { - if (!this.windows.has(id)) { - return; - } - - let windowInfo = this.windows.get(id) - windowInfo.title = browser.i18n.getMessage(windowInfo.incognito ? - 'incognitoWindowTitle' : 'windowTitle', title); - - this.notifyObservers('window-title-updated', id, title); - } - - focusWindow(id) { - this.focusedWindowId = this.windows.has(id) ? id : - browser.windows.WINDOW_ID_NONE; - - this.notifyObservers('window-focus-changed', id); - } - - closeWindow(id) { - if (!this.windows.has(id)) { - return; - } - - this.windows.delete(id); - - if (id === this.focusedWindowId) { - this.focusedWindowId = browser.windows.WINDOW_ID_NONE; - } - - this.notifyObservers('window-closed', id); +async function onMenuHidden() { + lastMenuInstanceId = 0; + browser.menus.update('move-menu', {enabled: false}); + browser.menus.update('reopen-menu', {enabled: false}); + for (let menuId of windowMenuIds) { + browser.menus.remove(menuId); } } -class MenuView { - constructor(model) { - this.model = model; - this.moveMenuIds = new Set(); - this.reopenMenuIds = new Set(); - this.menuContexts = ['tab']; - - browser.runtime.getBrowserInfo().then(browserInfo => { - // Firefox before version 53 does not support tab context menus - let majorVersion = browserInfo.version.match(/^\d+/); - if (majorVersion !== null && majorVersion < 53) { - this.menuContexts = ['all']; - } - - return Promise.all([ - // create submenus - createContextMenuItem({ - id: 'move-menu', - title: browser.i18n.getMessage('moveToWindowMenu'), - enabled: false, - contexts: this.menuContexts - }), - createContextMenuItem({ - id: 'reopen-menu', - title: browser.i18n.getMessage('reopenInWindowMenu'), - enabled: false, - contexts: this.menuContexts - }) - ]); - }).then(values => { - this.model.addObserver('window-opened', - this.onWindowOpened.bind(this)); - this.model.addObserver('window-title-updated', - this.onWindowTitleUpdated.bind(this)); - this.model.addObserver('window-focus-changed', - this.onWindowFocusChanged.bind(this)); - this.model.addObserver('window-closed', - this.onWindowClosed.bind(this)); - }).catch(error => { - console.log('Error:', error); - }); - } - - enableMenus() { - return Promise.all([ - browser.contextMenus.update('move-menu', { - enabled: this.moveMenuIds.size > 0 - }), - browser.contextMenus.update('reopen-menu', { - enabled: this.reopenMenuIds.size > 0 - }) - ]); - } - - onWindowOpened(eventName, windowId) { - let focusedWindowId = this.model.getfocusedWindowId(); - if (focusedWindowId === browser.windows.WINDOW_ID_NONE) { - // no window is focused so there is no need to update the menu - return; - } - - let menuId = String(windowId); - let windowInfo = this.model.getWindow(windowId); - let incognito = this.model.getWindow(focusedWindowId).incognito; - - if (incognito !== windowInfo.incognito) { - this.reopenMenuIds.add(menuId); - } else { - this.moveMenuIds.add(menuId); - } - - createContextMenuItem({ - id: menuId, - title: windowInfo.title, - contexts: this.menuContexts, - parentId: (incognito !== windowInfo.incognito) ? - 'reopen-menu' : 'move-menu' - }).then(() => { - return this.enableMenus(); - }).catch(error => { - console.log('Error:', error); - }); - } - - onWindowTitleUpdated(eventName, windowId, title) { - if (this.model.getfocusedWindowId() === - browser.windows.WINDOW_ID_NONE) { - // no window is focused so there is no need to update the menu - return; - } - - browser.contextMenus.update(String(windowId), {title}).catch(error => { - console.log('Error:', error); - }); - } - - onWindowFocusChanged(eventName, newWindowId) { - let promises = [ - // disable submenus - browser.contextMenus.update('move-menu', { - enabled: false - }), - browser.contextMenus.update('reopen-menu', { - enabled: false - }) - ]; - - if (newWindowId === browser.windows.WINDOW_ID_NONE) { - // just disable the submenus if focus moved to a window not tracked - Promise.all(promises).catch(error => { - console.log('Error:', error); - }); - return; - } - - Promise.all(promises).then(values => { - // remove all submenu items - let promises = new Array(...this.moveMenuIds, - ...this.reopenMenuIds).map(menuId => { - this.moveMenuIds.delete(menuId) || - this.reopenMenuIds.delete(menuId); - - return browser.contextMenus.remove(menuId); - }); - - return Promise.all(promises); - }).then(values => { - let incognito = this.model.getWindow(newWindowId).incognito; - - // rebuild submenus - let promises = []; - for (let windowInfo of this.model.getAllWindows()) { - if (windowInfo.id === newWindowId) { - // skip the currently focused window - continue; - } - - let menuId = String(windowInfo.id); - if (incognito !== windowInfo.incognito) { - this.reopenMenuIds.add(menuId); - } else { - this.moveMenuIds.add(menuId); - } - - // create menu item - promises.push(createContextMenuItem({ - id: menuId, - title: windowInfo.title, - contexts: this.menuContexts, - parentId: (incognito !== windowInfo.incognito) ? - 'reopen-menu' : 'move-menu' - })); - } - - return Promise.all(promises); - }).then(values => { - return this.enableMenus(); - }).catch(error => { - console.log('Error:', error); - }); - } - - onWindowClosed(eventName, windowId) { - if (this.model.getfocusedWindowId() === - browser.windows.WINDOW_ID_NONE) { - return; - } - - let menuId = String(windowId); - - this.moveMenuIds.delete(menuId) || this.reopenMenuIds.delete(menuId); - - browser.contextMenus.remove(menuId).then(() => { - return this.enableMenus(); - }).catch(error => { - console.log('Error:', error); - }); - } -} - -class Presenter { - constructor(model, view) { - this.model = model; - this.view = view; - - browser.windows.getAll({windowTypes: ['normal']}).then(windows => { - // populate model with existing windows - for (let windowInfo of windows) { - this.onWindowCreated(windowInfo); - - if (windowInfo.focused) { - this.onWindowFocusChanged(windowInfo.id); - } - } - - browser.windows.onCreated - .addListener(this.onWindowCreated.bind(this)); - browser.windows.onRemoved - .addListener(this.onWindowRemoved.bind(this)); - browser.windows.onFocusChanged - .addListener(this.onWindowFocusChanged.bind(this)); - browser.contextMenus.onClicked - .addListener(this.onMenuItemClicked.bind(this)); - }).catch(error => { - console.log('Error:', error); - }); - } - - onWindowCreated(windowInfo) { - // only track normal windows - if (windowInfo.type !== 'normal') { - return; - } - - this.model.openWindow(windowInfo.id, windowInfo.incognito); - - // get the window title and update the model - browser.tabs.query({ - active: true, - windowId: windowInfo.id - }).then(tabs => { - this.model.updateWindowTitle(tabs[0].windowId, tabs[0].title) - }).catch(error => { - console.log('Error:', error); - }); - } - - onWindowRemoved(windowId) { - this.model.closeWindow(windowId); - } - - onWindowFocusChanged(windowId) { - let prevFocusedWindowId = this.model.getfocusedWindowId(); - if (prevFocusedWindowId !== browser.windows.WINDOW_ID_NONE) { - // get title of the previously focused window and update the model - browser.tabs.query({ - active: true, - windowId: prevFocusedWindowId - }).then(tabs => { - this.model.updateWindowTitle(tabs[0].windowId, tabs[0].title) - }).catch(error => { - console.log('Error:', error); - }); - } - - this.model.focusWindow(windowId); - } - - onMenuItemClicked(info, tab) { - var windowId = parseInt(info.menuItemId); - - if (info.parentMenuItemId === 'move-menu') { - // move tab from the current window to the selected window - browser.tabs.move(tab.id, { - windowId, - index: -1 - }).catch(error => { - console.log('Error:', error); - }); - } else { - // open the URL of the current tab in the destination window - browser.tabs.create({ - url: tab.url, - windowId, - }).then(newTab => { - // close the current tab - return browser.tabs.remove(tab.id); - }).then(() => { - // get the new title of the destination window - return browser.tabs.query({ - active: true, - windowId - }); - }).then(tabs => { - this.model.updateWindowTitle(windowId, tabs[0].title) - }).catch(error => { - console.log('Error:', error); - }); - } - } -} - -let windowsModel = new WindowsModel(); -let menuView = new MenuView(windowsModel); -let presenter = new Presenter(windowsModel, menuView); +(async () => { + await Promise.all([ + // create submenus + createMenuItem({ + id: 'move-menu', + title: browser.i18n.getMessage('moveToWindowMenu'), + enabled: false, + contexts: ['tab'] + }), + createMenuItem({ + id: 'reopen-menu', + title: browser.i18n.getMessage('reopenInWindowMenu'), + enabled: false, + contexts: ['tab'] + }) + ]); + browser.menus.onShown.addListener(onMenuShown); + browser.menus.onHidden.addListener(onMenuHidden); +})(); diff -r 70de81c7c512 -r 4704e5216412 manifest.json.in --- a/manifest.json.in Thu May 31 14:07:49 2018 +0200 +++ b/manifest.json.in Sun Nov 25 13:27:47 2018 +0100 @@ -8,7 +8,7 @@ "applications": { "gecko": { "id": "tab-mover@code.guido-berhoerster.org", - "strict_min_version": "51.0" + "strict_min_version": "60.0" } }, "icons": { @@ -17,7 +17,7 @@ }, "default_locale": "en", "permissions": [ - "contextMenus", + "menus", "tabs" ], "background": {