addons/firefox-addons/tab-mover

changeset 23:4704e5216412

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.
author Guido Berhoerster <guido+tab-mover@berhoerster.name>
date Sun Nov 25 13:27:47 2018 +0100 (2018-11-25)
parents 70de81c7c512
children f418a6305f17
files background.js manifest.json.in
line diff
     1.1 --- a/background.js	Thu May 31 14:07:49 2018 +0200
     1.2 +++ b/background.js	Sun Nov 25 13:27:47 2018 +0100
     1.3 @@ -1,5 +1,5 @@
     1.4  /*
     1.5 - * Copyright (C) 2017 Guido Berhoerster <guido+tab-mover@berhoerster.name>
     1.6 + * Copyright (C) 2018 Guido Berhoerster <guido+tab-mover@berhoerster.name>
     1.7   *
     1.8   * This Source Code Form is subject to the terms of the Mozilla Public
     1.9   * License, v. 2.0. If a copy of the MPL was not distributed with this
    1.10 @@ -8,397 +8,113 @@
    1.11  
    1.12  'use strict';
    1.13  
    1.14 -function createContextMenuItem(createProperties) {
    1.15 +const ALLOWED_PROTOCOLS = new Set(['http:', 'https:', 'ftp:']);
    1.16 +var windowMenuIds = [];
    1.17 +var lastMenuInstanceId = 0;
    1.18 +var nextMenuInstanceId = 1;
    1.19 +
    1.20 +function createMenuItem(createProperties) {
    1.21      return new Promise((resolve, reject) => {
    1.22 -        browser.contextMenus.create(createProperties, () => {
    1.23 +        let id = browser.menus.create(createProperties, () => {
    1.24              if (browser.runtime.lastError) {
    1.25                  reject(browser.runtime.lastError);
    1.26              } else {
    1.27 -                resolve();
    1.28 +                resolve(id);
    1.29              }
    1.30          });
    1.31      });
    1.32  }
    1.33  
    1.34 -const Observable = (superclass) => class extends superclass {
    1.35 -    constructor(...args) {
    1.36 -        super(...args);
    1.37 +async function moveTab(tab, targetWindowId) {
    1.38 +    browser.tabs.move(tab.id, {windowId: targetWindowId, index: -1});
    1.39 +}
    1.40  
    1.41 -        this._observers = new Map();
    1.42 +async function reopenTab(tab, targetWindowId) {
    1.43 +    if (!ALLOWED_PROTOCOLS.has(new URL(tab.url).protocol)) {
    1.44 +        // privileged tab URL which cannot be reopened
    1.45 +        return;
    1.46      }
    1.47 +    await browser.tabs.create({
    1.48 +        url: tab.url,
    1.49 +        windowId: targetWindowId
    1.50 +    });
    1.51 +    browser.tabs.remove(tab.id);
    1.52 +}
    1.53  
    1.54 -    addObserver(eventName, observer) {
    1.55 -        if (!this._observers.has(eventName)) {
    1.56 -            this._observers.set(eventName, new Set());
    1.57 +async function onMenuShown(info, tab)  {
    1.58 +    let menuInstanceId = nextMenuInstanceId++;
    1.59 +    lastMenuInstanceId = menuInstanceId;
    1.60 +    let targetWindows = await browser.windows.getAll({
    1.61 +        populate: true,
    1.62 +        windowTypes: ['normal']
    1.63 +    });
    1.64 +    let creatingMenus = [];
    1.65 +    let moveMenuItems = 0;
    1.66 +    let reopenMenuItems = 0;
    1.67 +    for (let targetWindow of targetWindows) {
    1.68 +        if (targetWindow.id === tab.windowId) {
    1.69 +            // ignore active window
    1.70 +            continue;
    1.71          }
    1.72 -
    1.73 -        this._observers.get(eventName).add(observer);
    1.74 -    }
    1.75 -
    1.76 -    deleteObserver(eventName, observer) {
    1.77 -        if (this._observers.has(eventName)) {
    1.78 -            this._observers.get(eventName).delete(observer);
    1.79 +        if (tab.incognito === targetWindow.incognito) {
    1.80 +            creatingMenus.push(createMenuItem({
    1.81 +                onclick: (info, tab) => moveTab(tab, targetWindow.id),
    1.82 +                parentId: 'move-menu',
    1.83 +                title: targetWindow.title
    1.84 +            }));
    1.85 +            moveMenuItems++;
    1.86 +        } else {
    1.87 +            creatingMenus.push(createMenuItem({
    1.88 +                onclick: (info, tab) => reopenTab(tab, targetWindow.id),
    1.89 +                parentId: 'reopen-menu',
    1.90 +                title: targetWindow.title
    1.91 +            }));
    1.92 +            reopenMenuItems++;
    1.93          }
    1.94      }
    1.95 +    let updatingMenus = [
    1.96 +        browser.menus.update('move-menu', {enabled: moveMenuItems > 0}),
    1.97 +        browser.menus.update('reopen-menu', {enabled: reopenMenuItems > 0})
    1.98 +    ];
    1.99 +    await Promise.all([...creatingMenus, ...updatingMenus]);
   1.100 +    let newWindowMenuIds = await Promise.all(creatingMenus);
   1.101 +    if (menuInstanceId !== lastMenuInstanceId) {
   1.102 +        // menu has been closed and opened again, remove the items of this
   1.103 +        // instance again
   1.104 +        for (let menuId of newWindowMenuIds) {
   1.105 +            browser.menus.remove(menuId);
   1.106 +        }
   1.107 +        return;
   1.108 +    }
   1.109 +    windowMenuIds = newWindowMenuIds;
   1.110 +    browser.menus.refresh();
   1.111 +}
   1.112  
   1.113 -    notifyObservers(eventName, ...args) {
   1.114 -        if (!this._observers.has(eventName)) {
   1.115 -            return;
   1.116 -        }
   1.117 -
   1.118 -        for (let observer of this._observers.get(eventName)) {
   1.119 -            observer(eventName, ...args);
   1.120 -        }
   1.121 +async function onMenuHidden() {
   1.122 +    lastMenuInstanceId = 0;
   1.123 +    browser.menus.update('move-menu', {enabled: false});
   1.124 +    browser.menus.update('reopen-menu', {enabled: false});
   1.125 +    for (let menuId of windowMenuIds) {
   1.126 +        browser.menus.remove(menuId);
   1.127      }
   1.128  }
   1.129  
   1.130 -class WindowsModel extends Observable(Object) {
   1.131 -    constructor() {
   1.132 -        super();
   1.133 -
   1.134 -        this.windows = new Map();
   1.135 -        this.focusedWindowId = browser.windows.WINDOW_ID_NONE;
   1.136 -    }
   1.137 -
   1.138 -    getWindow(id) {
   1.139 -        return this.windows.get(id);
   1.140 -    }
   1.141 -
   1.142 -    getAllWindows() {
   1.143 -        return this.windows.values();
   1.144 -    }
   1.145 -
   1.146 -    getfocusedWindowId() {
   1.147 -        return this.focusedWindowId;
   1.148 -    }
   1.149 -
   1.150 -    openWindow(id, incognito = false) {
   1.151 -        this.windows.set(id, {
   1.152 -            id,
   1.153 -            title: browser.i18n.getMessage(incognito ?
   1.154 -                'defaultIncognitoWindowTitle' : 'defaultWindowTitle', id),
   1.155 -            incognito
   1.156 -        });
   1.157 -
   1.158 -        this.notifyObservers('window-opened', id);
   1.159 -    }
   1.160 -
   1.161 -    updateWindowTitle(id, title) {
   1.162 -        if (!this.windows.has(id)) {
   1.163 -            return;
   1.164 -        }
   1.165 -
   1.166 -        let windowInfo = this.windows.get(id)
   1.167 -        windowInfo.title = browser.i18n.getMessage(windowInfo.incognito ?
   1.168 -            'incognitoWindowTitle' : 'windowTitle', title);
   1.169 -
   1.170 -        this.notifyObservers('window-title-updated', id, title);
   1.171 -    }
   1.172 -
   1.173 -    focusWindow(id) {
   1.174 -        this.focusedWindowId = this.windows.has(id) ? id :
   1.175 -            browser.windows.WINDOW_ID_NONE;
   1.176 -
   1.177 -        this.notifyObservers('window-focus-changed', id);
   1.178 -    }
   1.179 -
   1.180 -    closeWindow(id) {
   1.181 -        if (!this.windows.has(id)) {
   1.182 -            return;
   1.183 -        }
   1.184 -
   1.185 -        this.windows.delete(id);
   1.186 -
   1.187 -        if (id === this.focusedWindowId) {
   1.188 -            this.focusedWindowId = browser.windows.WINDOW_ID_NONE;
   1.189 -        }
   1.190 -
   1.191 -        this.notifyObservers('window-closed', id);
   1.192 -    }
   1.193 -}
   1.194 -
   1.195 -class MenuView {
   1.196 -    constructor(model) {
   1.197 -        this.model = model;
   1.198 -        this.moveMenuIds = new Set();
   1.199 -        this.reopenMenuIds = new Set();
   1.200 -        this.menuContexts = ['tab'];
   1.201 -
   1.202 -        browser.runtime.getBrowserInfo().then(browserInfo => {
   1.203 -            // Firefox before version 53 does not support tab context menus
   1.204 -            let majorVersion = browserInfo.version.match(/^\d+/);
   1.205 -            if (majorVersion !== null && majorVersion < 53) {
   1.206 -                this.menuContexts = ['all'];
   1.207 -            }
   1.208 -
   1.209 -            return Promise.all([
   1.210 -                // create submenus
   1.211 -                createContextMenuItem({
   1.212 -                    id: 'move-menu',
   1.213 -                    title: browser.i18n.getMessage('moveToWindowMenu'),
   1.214 -                    enabled: false,
   1.215 -                    contexts: this.menuContexts
   1.216 -                }),
   1.217 -                createContextMenuItem({
   1.218 -                    id: 'reopen-menu',
   1.219 -                    title: browser.i18n.getMessage('reopenInWindowMenu'),
   1.220 -                    enabled: false,
   1.221 -                    contexts: this.menuContexts
   1.222 -                })
   1.223 -            ]);
   1.224 -        }).then(values => {
   1.225 -            this.model.addObserver('window-opened',
   1.226 -                this.onWindowOpened.bind(this));
   1.227 -            this.model.addObserver('window-title-updated',
   1.228 -                this.onWindowTitleUpdated.bind(this));
   1.229 -            this.model.addObserver('window-focus-changed',
   1.230 -                this.onWindowFocusChanged.bind(this));
   1.231 -            this.model.addObserver('window-closed',
   1.232 -                this.onWindowClosed.bind(this));
   1.233 -        }).catch(error => {
   1.234 -            console.log('Error:', error);
   1.235 -        });
   1.236 -    }
   1.237 -
   1.238 -    enableMenus() {
   1.239 -        return Promise.all([
   1.240 -            browser.contextMenus.update('move-menu', {
   1.241 -                enabled: this.moveMenuIds.size > 0
   1.242 -            }),
   1.243 -            browser.contextMenus.update('reopen-menu', {
   1.244 -                enabled: this.reopenMenuIds.size > 0
   1.245 -            })
   1.246 -        ]);
   1.247 -    }
   1.248 -
   1.249 -    onWindowOpened(eventName, windowId) {
   1.250 -        let focusedWindowId = this.model.getfocusedWindowId();
   1.251 -        if (focusedWindowId === browser.windows.WINDOW_ID_NONE) {
   1.252 -            // no window is focused so there is no need to update the menu
   1.253 -            return;
   1.254 -        }
   1.255 -
   1.256 -        let menuId = String(windowId);
   1.257 -        let windowInfo = this.model.getWindow(windowId);
   1.258 -        let incognito = this.model.getWindow(focusedWindowId).incognito;
   1.259 -
   1.260 -        if (incognito !== windowInfo.incognito) {
   1.261 -            this.reopenMenuIds.add(menuId);
   1.262 -        } else {
   1.263 -            this.moveMenuIds.add(menuId);
   1.264 -        }
   1.265 -
   1.266 -        createContextMenuItem({
   1.267 -            id: menuId,
   1.268 -            title: windowInfo.title,
   1.269 -            contexts: this.menuContexts,
   1.270 -            parentId: (incognito !== windowInfo.incognito) ?
   1.271 -                'reopen-menu' : 'move-menu'
   1.272 -        }).then(() => {
   1.273 -            return this.enableMenus();
   1.274 -        }).catch(error => {
   1.275 -            console.log('Error:', error);
   1.276 -        });
   1.277 -    }
   1.278 -
   1.279 -    onWindowTitleUpdated(eventName, windowId, title) {
   1.280 -        if (this.model.getfocusedWindowId() ===
   1.281 -            browser.windows.WINDOW_ID_NONE) {
   1.282 -            // no window is focused so there is no need to update the menu
   1.283 -            return;
   1.284 -        }
   1.285 -
   1.286 -        browser.contextMenus.update(String(windowId), {title}).catch(error => {
   1.287 -            console.log('Error:', error);
   1.288 -        });
   1.289 -    }
   1.290 -
   1.291 -    onWindowFocusChanged(eventName, newWindowId) {
   1.292 -        let promises = [
   1.293 -            // disable submenus
   1.294 -            browser.contextMenus.update('move-menu', {
   1.295 -                enabled: false
   1.296 -            }),
   1.297 -            browser.contextMenus.update('reopen-menu', {
   1.298 -                enabled: false
   1.299 -            })
   1.300 -        ];
   1.301 -
   1.302 -        if (newWindowId === browser.windows.WINDOW_ID_NONE) {
   1.303 -            // just disable the submenus if focus moved to a window not tracked
   1.304 -            Promise.all(promises).catch(error => {
   1.305 -                console.log('Error:', error);
   1.306 -            });
   1.307 -            return;
   1.308 -        }
   1.309 -
   1.310 -        Promise.all(promises).then(values => {
   1.311 -            // remove all submenu items
   1.312 -            let promises = new Array(...this.moveMenuIds,
   1.313 -                ...this.reopenMenuIds).map(menuId => {
   1.314 -                this.moveMenuIds.delete(menuId) ||
   1.315 -                    this.reopenMenuIds.delete(menuId);
   1.316 -
   1.317 -                return browser.contextMenus.remove(menuId);
   1.318 -            });
   1.319 -
   1.320 -            return Promise.all(promises);
   1.321 -        }).then(values => {
   1.322 -            let incognito = this.model.getWindow(newWindowId).incognito;
   1.323 -
   1.324 -            // rebuild submenus
   1.325 -            let promises = [];
   1.326 -            for (let windowInfo of this.model.getAllWindows()) {
   1.327 -                if (windowInfo.id === newWindowId) {
   1.328 -                    // skip the currently focused window
   1.329 -                    continue;
   1.330 -                }
   1.331 -
   1.332 -                let menuId = String(windowInfo.id);
   1.333 -                if (incognito !== windowInfo.incognito) {
   1.334 -                    this.reopenMenuIds.add(menuId);
   1.335 -                } else {
   1.336 -                    this.moveMenuIds.add(menuId);
   1.337 -                }
   1.338 -
   1.339 -                // create menu item
   1.340 -                promises.push(createContextMenuItem({
   1.341 -                    id: menuId,
   1.342 -                    title: windowInfo.title,
   1.343 -                    contexts: this.menuContexts,
   1.344 -                    parentId: (incognito !== windowInfo.incognito) ?
   1.345 -                        'reopen-menu' : 'move-menu'
   1.346 -                }));
   1.347 -            }
   1.348 -
   1.349 -            return Promise.all(promises);
   1.350 -        }).then(values => {
   1.351 -            return this.enableMenus();
   1.352 -        }).catch(error => {
   1.353 -            console.log('Error:', error);
   1.354 -        });
   1.355 -    }
   1.356 -
   1.357 -    onWindowClosed(eventName, windowId) {
   1.358 -        if (this.model.getfocusedWindowId() ===
   1.359 -            browser.windows.WINDOW_ID_NONE) {
   1.360 -            return;
   1.361 -        }
   1.362 -
   1.363 -        let menuId = String(windowId);
   1.364 -
   1.365 -        this.moveMenuIds.delete(menuId) || this.reopenMenuIds.delete(menuId);
   1.366 -
   1.367 -        browser.contextMenus.remove(menuId).then(() => {
   1.368 -            return this.enableMenus();
   1.369 -        }).catch(error => {
   1.370 -            console.log('Error:', error);
   1.371 -        });
   1.372 -    }
   1.373 -}
   1.374 -
   1.375 -class Presenter {
   1.376 -    constructor(model, view) {
   1.377 -        this.model = model;
   1.378 -        this.view = view;
   1.379 -
   1.380 -        browser.windows.getAll({windowTypes: ['normal']}).then(windows => {
   1.381 -            // populate model with existing windows
   1.382 -            for (let windowInfo of windows) {
   1.383 -                this.onWindowCreated(windowInfo);
   1.384 -
   1.385 -                if (windowInfo.focused) {
   1.386 -                    this.onWindowFocusChanged(windowInfo.id);
   1.387 -                }
   1.388 -            }
   1.389 -
   1.390 -            browser.windows.onCreated
   1.391 -                .addListener(this.onWindowCreated.bind(this));
   1.392 -            browser.windows.onRemoved
   1.393 -                .addListener(this.onWindowRemoved.bind(this));
   1.394 -            browser.windows.onFocusChanged
   1.395 -                .addListener(this.onWindowFocusChanged.bind(this));
   1.396 -            browser.contextMenus.onClicked
   1.397 -                .addListener(this.onMenuItemClicked.bind(this));
   1.398 -        }).catch(error => {
   1.399 -            console.log('Error:', error);
   1.400 -        });
   1.401 -    }
   1.402 -
   1.403 -    onWindowCreated(windowInfo) {
   1.404 -        // only track normal windows
   1.405 -        if (windowInfo.type !== 'normal') {
   1.406 -            return;
   1.407 -        }
   1.408 -
   1.409 -        this.model.openWindow(windowInfo.id, windowInfo.incognito);
   1.410 -
   1.411 -        // get the window title and update the model
   1.412 -        browser.tabs.query({
   1.413 -            active: true,
   1.414 -            windowId: windowInfo.id
   1.415 -        }).then(tabs => {
   1.416 -            this.model.updateWindowTitle(tabs[0].windowId, tabs[0].title)
   1.417 -        }).catch(error => {
   1.418 -            console.log('Error:', error);
   1.419 -        });
   1.420 -    }
   1.421 -
   1.422 -    onWindowRemoved(windowId) {
   1.423 -        this.model.closeWindow(windowId);
   1.424 -    }
   1.425 -
   1.426 -    onWindowFocusChanged(windowId) {
   1.427 -        let prevFocusedWindowId = this.model.getfocusedWindowId();
   1.428 -        if (prevFocusedWindowId !== browser.windows.WINDOW_ID_NONE) {
   1.429 -            // get title of the previously focused window and update the model
   1.430 -            browser.tabs.query({
   1.431 -                active: true,
   1.432 -                windowId: prevFocusedWindowId
   1.433 -            }).then(tabs => {
   1.434 -                this.model.updateWindowTitle(tabs[0].windowId, tabs[0].title)
   1.435 -            }).catch(error => {
   1.436 -                console.log('Error:', error);
   1.437 -            });
   1.438 -        }
   1.439 -
   1.440 -        this.model.focusWindow(windowId);
   1.441 -    }
   1.442 -
   1.443 -    onMenuItemClicked(info, tab) {
   1.444 -        var windowId = parseInt(info.menuItemId);
   1.445 -
   1.446 -        if (info.parentMenuItemId === 'move-menu') {
   1.447 -            // move tab from the current window to the selected window
   1.448 -            browser.tabs.move(tab.id, {
   1.449 -                windowId,
   1.450 -                index: -1
   1.451 -            }).catch(error => {
   1.452 -                console.log('Error:', error);
   1.453 -            });
   1.454 -        } else {
   1.455 -            // open the URL of the current tab in the destination window
   1.456 -            browser.tabs.create({
   1.457 -                url: tab.url,
   1.458 -                windowId,
   1.459 -            }).then(newTab => {
   1.460 -                // close the current tab
   1.461 -                return browser.tabs.remove(tab.id);
   1.462 -            }).then(() => {
   1.463 -                // get the new title of the destination window
   1.464 -                return browser.tabs.query({
   1.465 -                    active: true,
   1.466 -                    windowId
   1.467 -                });
   1.468 -            }).then(tabs => {
   1.469 -                this.model.updateWindowTitle(windowId, tabs[0].title)
   1.470 -            }).catch(error => {
   1.471 -                console.log('Error:', error);
   1.472 -            });
   1.473 -        }
   1.474 -    }
   1.475 -}
   1.476 -
   1.477 -let windowsModel = new WindowsModel();
   1.478 -let menuView = new MenuView(windowsModel);
   1.479 -let presenter = new Presenter(windowsModel, menuView);
   1.480 +(async () => {
   1.481 +    await Promise.all([
   1.482 +        // create submenus
   1.483 +        createMenuItem({
   1.484 +            id: 'move-menu',
   1.485 +            title: browser.i18n.getMessage('moveToWindowMenu'),
   1.486 +            enabled: false,
   1.487 +            contexts: ['tab']
   1.488 +        }),
   1.489 +        createMenuItem({
   1.490 +            id: 'reopen-menu',
   1.491 +            title: browser.i18n.getMessage('reopenInWindowMenu'),
   1.492 +            enabled: false,
   1.493 +            contexts: ['tab']
   1.494 +        })
   1.495 +    ]);
   1.496 +    browser.menus.onShown.addListener(onMenuShown);
   1.497 +    browser.menus.onHidden.addListener(onMenuHidden);
   1.498 +})();
     2.1 --- a/manifest.json.in	Thu May 31 14:07:49 2018 +0200
     2.2 +++ b/manifest.json.in	Sun Nov 25 13:27:47 2018 +0100
     2.3 @@ -8,7 +8,7 @@
     2.4    "applications": {
     2.5        "gecko": {
     2.6            "id": "tab-mover@code.guido-berhoerster.org",
     2.7 -          "strict_min_version": "51.0"
     2.8 +          "strict_min_version": "60.0"
     2.9        }
    2.10    },
    2.11    "icons": {
    2.12 @@ -17,7 +17,7 @@
    2.13    },
    2.14    "default_locale": "en",
    2.15    "permissions": [
    2.16 -    "contextMenus",
    2.17 +    "menus",
    2.18      "tabs"
    2.19    ],
    2.20    "background": {