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, 25 Nov 2018 13:27:47 +0100
parents 70de81c7c512
children f418a6305f17
files background.js manifest.json.in
diffstat 2 files changed, 94 insertions(+), 378 deletions(-) [+]
line wrap: on
line diff
--- 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 <guido+tab-mover@berhoerster.name>
+ * Copyright (C) 2018 Guido Berhoerster <guido+tab-mover@berhoerster.name>
  *
  * 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);
+})();
--- 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": {