├── .gitignore ├── res └── icons │ ├── 16.png │ ├── 32.png │ └── 48.png ├── docs └── images │ ├── group-tab.png │ ├── tab-in-group.png │ ├── add-to-new-group.png │ ├── add-to-existing-group.png │ ├── hidden-tabs-warning.png │ ├── tab-grouping-functionality.gif │ └── remove-and-move-group-options.png ├── zipme.sh ├── manifest.json ├── src ├── background.js ├── group-tab │ ├── custom-elements │ │ ├── error.js │ │ ├── labeled-color-picker.js │ │ ├── tab.js │ │ └── labeled-checkbox.js │ ├── index.html │ ├── index.css │ └── index.js ├── windows.js ├── runtime.js ├── menus.js ├── communication.js ├── groups.js └── tabs.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | browser-tab-grouper.zip -------------------------------------------------------------------------------- /res/icons/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNamlessGuy/browser-tab-grouper/HEAD/res/icons/16.png -------------------------------------------------------------------------------- /res/icons/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNamlessGuy/browser-tab-grouper/HEAD/res/icons/32.png -------------------------------------------------------------------------------- /res/icons/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNamlessGuy/browser-tab-grouper/HEAD/res/icons/48.png -------------------------------------------------------------------------------- /docs/images/group-tab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNamlessGuy/browser-tab-grouper/HEAD/docs/images/group-tab.png -------------------------------------------------------------------------------- /docs/images/tab-in-group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNamlessGuy/browser-tab-grouper/HEAD/docs/images/tab-in-group.png -------------------------------------------------------------------------------- /docs/images/add-to-new-group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNamlessGuy/browser-tab-grouper/HEAD/docs/images/add-to-new-group.png -------------------------------------------------------------------------------- /docs/images/add-to-existing-group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNamlessGuy/browser-tab-grouper/HEAD/docs/images/add-to-existing-group.png -------------------------------------------------------------------------------- /docs/images/hidden-tabs-warning.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNamlessGuy/browser-tab-grouper/HEAD/docs/images/hidden-tabs-warning.png -------------------------------------------------------------------------------- /docs/images/tab-grouping-functionality.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNamlessGuy/browser-tab-grouper/HEAD/docs/images/tab-grouping-functionality.gif -------------------------------------------------------------------------------- /docs/images/remove-and-move-group-options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TheNamlessGuy/browser-tab-grouper/HEAD/docs/images/remove-and-move-group-options.png -------------------------------------------------------------------------------- /zipme.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | tmp() { 4 | local file="browser-tab-grouper.zip" 5 | 6 | if [[ -f "./${file}" ]]; then 7 | \rm -i "./${file}" 8 | if [[ -f "./${file}" ]]; then 9 | echo >&2 "./${file}" 10 | exit 1 11 | fi 12 | fi 13 | 14 | echo "Zipping..." 15 | zip -r -q "./${file}" res/ src/ manifest.json 16 | } 17 | 18 | tmp -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "name": "Tab Grouper", 4 | "version": "0.6.0", 5 | "description": "Groups tabs", 6 | "author": "TheNamlessGuy", 7 | "homepage_url": "https://github.com/TheNamlessGuy/browser-tab-grouper", 8 | "developer": { 9 | "name": "TheNamlessGuy", 10 | "url": "https://github.com/TheNamlessGuy/browser-tab-grouper" 11 | }, 12 | 13 | "icons": { 14 | "48": "res/icons/48.png", 15 | "32": "res/icons/32.png", 16 | "16": "res/icons/16.png" 17 | }, 18 | 19 | "permissions": [ 20 | "tabs", 21 | "tabHide", 22 | "menus", 23 | "sessions" 24 | ], 25 | 26 | "background": { 27 | "scripts": [ 28 | "src/runtime.js", 29 | "src/communication.js", 30 | "src/menus.js", 31 | "src/tabs.js", 32 | "src/groups.js", 33 | "src/windows.js", 34 | 35 | "src/background.js" 36 | ] 37 | }, 38 | 39 | "browser_specific_settings": { 40 | "gecko": { 41 | "id": "tab-grouper-live@TheNamlessGuy.github.io" 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | const Background = { 2 | /** 3 | * @returns {Promise} 4 | */ 5 | main: async function() { 6 | Communication.init(); 7 | await Runtime.init(); 8 | await Tabs.init(); 9 | await Groups.init(); 10 | await Menus.init(); 11 | }, 12 | 13 | /** 14 | * @param {URL} a 15 | * @param {URL} b 16 | * @returns {boolean} 17 | */ 18 | urlEqual: function(a, b) { 19 | if (a.protocol != b.protocol) { return false; } 20 | if (a.host != b.host) { return false; } 21 | if (a.pathname != b.pathname) { return false; } 22 | 23 | const aParams = Object.fromEntries(a.searchParams); 24 | const bParams = Object.fromEntries(b.searchParams); 25 | 26 | const aKeys = Object.keys(aParams); 27 | const bKeys = Object.keys(bParams); 28 | if (aKeys.length != bKeys.length) { return false; } 29 | 30 | for (const aKey of aKeys) { 31 | if (!bKeys.includes(aKey)) { return false; } 32 | if (aParams[aKey] != bParams[aKey]) { return false; } 33 | } 34 | 35 | for (const bKey of bKeys) { 36 | if (!aKeys.includes(bKey)) { return false; } 37 | } 38 | 39 | return true; 40 | }, 41 | }; 42 | 43 | Background.main(); -------------------------------------------------------------------------------- /src/group-tab/custom-elements/error.js: -------------------------------------------------------------------------------- 1 | class CustomErrorElement extends HTMLElement { 2 | _message = null; 3 | 4 | constructor() { 5 | super(); 6 | 7 | const style = document.createElement('style'); 8 | style.textContent = ` 9 | @import url('/src/group-tab/index.css'); 10 | 11 | div { 12 | display: flex; 13 | justify-content: space-between; 14 | background-color: var(--error-color); 15 | color: var(--text-color); 16 | width: var(--content-container-width); 17 | border-radius: 5px; 18 | padding: 3px; 19 | margin: 3px 0; 20 | } 21 | 22 | button { 23 | border: none; 24 | background-color: inherit; 25 | } 26 | `; 27 | 28 | const container = document.createElement('div'); 29 | 30 | this._message = document.createElement('span'); 31 | container.append(this._message); 32 | 33 | const xButton = document.createElement('button'); 34 | xButton.innerText = '⨯'; 35 | xButton.addEventListener('click', () => { 36 | this.remove(); 37 | this.dispatchEvent(new Event('removed')); 38 | }); 39 | container.append(xButton); 40 | 41 | this.attachShadow({mode: 'closed'}).append(style, container); 42 | } 43 | 44 | set message(msg) { 45 | this._message.innerHTML = msg; 46 | } 47 | } 48 | 49 | window.addEventListener('DOMContentLoaded', () => customElements.define('c-error', CustomErrorElement)); -------------------------------------------------------------------------------- /src/group-tab/custom-elements/labeled-color-picker.js: -------------------------------------------------------------------------------- 1 | class LabeledColorPickerElement extends HTMLElement { 2 | _picker = null; 3 | 4 | constructor() { 5 | super(); 6 | 7 | const style = document.createElement('style'); 8 | style.textContent = ` 9 | @import url('/src/group-tab/index.css'); 10 | 11 | div { 12 | display: flex; 13 | align-items: center; 14 | } 15 | 16 | label { 17 | user-select: none; 18 | margin-left: 5px; 19 | } 20 | 21 | input { 22 | --size: 16px; 23 | cursor: pointer; 24 | width: var(--size); 25 | height: var(--size); 26 | padding: 1px; 27 | } 28 | `; 29 | 30 | const container = document.createElement('div'); 31 | 32 | this._picker = document.createElement('input'); 33 | this._picker.type = 'color'; 34 | this._picker.addEventListener('input', () => this.dispatchEvent(new Event('input'))); 35 | this._picker.addEventListener('change', () => this.dispatchEvent(new Event('change'))); 36 | container.append(this._picker); 37 | 38 | const label = document.createElement('label'); 39 | label.innerText = this.innerText; 40 | container.append(label); 41 | 42 | this.attachShadow({mode: 'closed'}).append(style, container); 43 | } 44 | 45 | get value() { 46 | return this._picker.value; 47 | } 48 | 49 | set value(value) { 50 | this._picker.value = value; 51 | } 52 | } 53 | 54 | window.addEventListener('DOMContentLoaded', () => customElements.define('labeled-color-picker', LabeledColorPickerElement)); -------------------------------------------------------------------------------- /src/group-tab/custom-elements/tab.js: -------------------------------------------------------------------------------- 1 | class CustomTabElement extends HTMLElement { 2 | _elements = { 3 | icon: null, 4 | title: null, 5 | removeBtn: null, 6 | }; 7 | 8 | constructor() { 9 | super(); 10 | 11 | const style = document.createElement('style'); 12 | style.textContent = ` 13 | @import url('/src/group-tab/index.css'); 14 | 15 | div { 16 | user-select: none; 17 | padding: 5px 0; 18 | display: flex; 19 | align-items: center; 20 | } 21 | 22 | img { 23 | width: 16px; 24 | height: 16px; 25 | margin-right: 5px; 26 | } 27 | span { cursor: pointer; } 28 | button { margin-right: 5px; } 29 | `; 30 | 31 | const container = document.createElement('div'); 32 | container.part = 'body'; 33 | 34 | this._elements.removeBtn = document.createElement('button'); 35 | this._elements.removeBtn.classList.add('red'); 36 | this._elements.removeBtn.innerText = '⨯'; 37 | this._elements.removeBtn.title = 'Remove this tab from the group'; 38 | this._elements.removeBtn.addEventListener('click', function() { this.dispatchEvent(new Event('remove-me')); }.bind(this)); 39 | container.append(this._elements.removeBtn); 40 | 41 | this._elements.icon = document.createElement('img'); 42 | container.append(this._elements.icon); 43 | 44 | this._elements.title = document.createElement('span'); 45 | this._elements.title.title = 'Swap to this tab'; 46 | this._elements.title.addEventListener('click', function() { this.dispatchEvent(new Event('swap-to-me')); }.bind(this)); 47 | container.append(this._elements.title); 48 | 49 | this.attachShadow({mode: 'closed'}).append(style, container); 50 | } 51 | 52 | /** 53 | * @param {BrowserTab} tab 54 | * @returns {void} 55 | */ 56 | setTabData(tab) { 57 | this._elements.icon.src = tab.favIconUrl; 58 | this._elements.title.innerText = tab.title; 59 | } 60 | } 61 | 62 | window.addEventListener('DOMContentLoaded', () => customElements.define('c-tab', CustomTabElement)); -------------------------------------------------------------------------------- /src/group-tab/custom-elements/labeled-checkbox.js: -------------------------------------------------------------------------------- 1 | class LabeledCheckboxElement extends HTMLElement { 2 | _checkbox = null; 3 | 4 | constructor() { 5 | super(); 6 | 7 | const style = document.createElement('style'); 8 | style.textContent = ` 9 | @import url('/src/group-tab/index.css'); 10 | 11 | div { 12 | display: flex; 13 | align-items: center; 14 | } 15 | 16 | label { 17 | user-select: none; 18 | cursor: pointer; 19 | margin-left: 5px; 20 | } 21 | 22 | span { 23 | --size: 14px; 24 | --half-size: calc(var(--size) / 2); 25 | --quarter-size: calc(var(--half-size) / 2); 26 | position: relative; 27 | display: inline-block; 28 | cursor: pointer; 29 | width: var(--size); 30 | height: var(--size); 31 | border: 1px solid var(--border-color); 32 | border-radius: 5px; 33 | } 34 | span:after { 35 | position: absolute; 36 | content: ''; 37 | } 38 | 39 | span.checked { 40 | background-color: var(--foreground-color); 41 | } 42 | span.checked:after { 43 | left: 4px; 44 | top: 1px; 45 | width: var(--quarter-size); 46 | height: var(--half-size); 47 | border-color: var(--text-color); 48 | border-width: 0 3px 3px 0; 49 | border-style: solid; 50 | transform: rotate(45deg); 51 | } 52 | `; 53 | 54 | const container = document.createElement('div'); 55 | 56 | this._checkbox = document.createElement('span'); 57 | this._checkbox.addEventListener('click', this._onClicked.bind(this)); 58 | container.append(this._checkbox); 59 | 60 | const label = document.createElement('label'); 61 | label.innerText = this.innerText; 62 | label.addEventListener('click', function() { this._checkbox.click(); }.bind(this)); 63 | container.append(label); 64 | 65 | this.attachShadow({mode: 'closed'}).append(style, container); 66 | } 67 | 68 | get checked() { 69 | return this._checkbox.classList.contains('checked'); 70 | } 71 | 72 | set checked(value) { 73 | this._checkbox.classList.toggle('checked', value); 74 | } 75 | 76 | _onClicked() { 77 | this._checkbox.classList.toggle('checked'); 78 | this.dispatchEvent(new Event('change')); 79 | } 80 | } 81 | 82 | window.addEventListener('DOMContentLoaded', () => customElements.define('labeled-checkbox', LabeledCheckboxElement)); -------------------------------------------------------------------------------- /src/group-tab/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Tab Grouper 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |
18 |
19 | 20 |
21 | 22 |
23 | Options 24 | 25 | Automatically open/collapse the tab group when entered/left 26 | Auto-select the last visited tab when this tab group is opened 27 | Tabs opened from within the group should be kept in the group 28 | Prompt me if I try to close this tab group 29 | Set the group icon color 30 |
31 | 32 |
33 | Actions 34 | 35 |
36 | 37 | 38 | 39 |
40 |
41 | 42 |
43 | Tabs 44 | 45 |
46 |
47 |
48 | 49 |
50 |

This tab is no longer associated with an active group, and is therefore safe to remove.

51 |

If you want to recreate this tab group, you'll have to start over again.

52 |
53 |
54 | 55 | -------------------------------------------------------------------------------- /src/windows.js: -------------------------------------------------------------------------------- 1 | /** BrowserWindow 2 | * @typedef {object} BrowserWindow https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/windows/Window 3 | * @property {number} id 4 | */ 5 | 6 | const Windows = { 7 | /** 8 | * @param {boolean|null} incognito When true, only return windows that are in incognito mode. When false, return windows that aren't incognito. When null, return all windows regardless 9 | * @returns {Promise} 10 | */ 11 | getAll: async function(incognito = null) { 12 | const windows = await browser.windows.getAll(); 13 | if (incognito == null) { 14 | return windows; 15 | } 16 | 17 | return windows.filter((window) => window.incognito === incognito); 18 | }, 19 | 20 | /** 21 | * @param {number} windowID 22 | * @returns {Promise} 23 | */ 24 | getAllTabsIn: async function(windowID) { 25 | return await browser.tabs.query({windowId: windowID}); 26 | }, 27 | 28 | /** 29 | * @param {number} windowID 30 | * @returns {Promise} 31 | */ 32 | getAllGroupsIn: async function(windowID) { 33 | const retval = []; 34 | 35 | const tabs = await Windows.getAllTabsIn(windowID); 36 | for (const tab of tabs) { 37 | const group = await Tabs.value.get.group(tab.id); 38 | if (group != null && !retval.includes(group)) { 39 | retval.push(group); 40 | } 41 | } 42 | 43 | return retval; 44 | }, 45 | 46 | /** 47 | * @param {number} windowID 48 | * @returns {string|null} 49 | */ 50 | getCurrentGroupIn: async function(windowID) { 51 | const tab = (await browser.tabs.query({windowId: windowID, active: true}))[0]; 52 | return await Tabs.value.get.group(tab.id); 53 | }, 54 | 55 | /** 56 | * Returns the ID of the window the group tab is in. If there is no group tab (uh oh!), return a window with at least one regular grouped tab in it (if any). 57 | * 58 | * @param {string} group 59 | * @param {number|null} exceptTab 60 | * @returns {Promise} 61 | */ 62 | getIDForGroup: async function(group, exceptTab = null) { 63 | let backup = null; 64 | 65 | const windows = await Windows.getAll(); 66 | for (const window of windows) { 67 | const tabs = await Windows.getAllTabsIn(window.id); 68 | for (const tab of tabs) { 69 | if (exceptTab != null && tab.id === exceptTab) { continue; } 70 | 71 | const tabGroup = await Tabs.value.get.group(tab.id); 72 | const isGroupTab = await Tabs.value.get.isGroupTab(tab.id); 73 | 74 | if (tabGroup === group) { 75 | if (isGroupTab) { 76 | return window.id; 77 | } 78 | 79 | backup = window.id; 80 | } 81 | } 82 | } 83 | 84 | return backup; 85 | }, 86 | }; -------------------------------------------------------------------------------- /src/runtime.js: -------------------------------------------------------------------------------- 1 | const Runtime = { 2 | /** 3 | * @returns {Promise} 4 | */ 5 | init: async function() { 6 | if (!browser.runtime.onInstalled.hasListener(Runtime._on.installed)) { 7 | browser.runtime.onInstalled.addListener(Runtime._on.installed); 8 | } 9 | 10 | // Runtime.init will always be called when the plugin is started, regardless of if the user flipped the enabled/disabled switch or if the browser itself restarted 11 | await Runtime._recreateMissingGroupTabs(); 12 | }, 13 | 14 | _on: { 15 | /** 16 | * @param {{id?: string, previousVersion?: string, reason: "install"|"update"|"browser_update"|"chrome_update"|"shared_module_update", temporary: boolean}} details https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/runtime/onInstalled#details 17 | * @returns {Promise} 18 | */ 19 | installed: async function(details) { 20 | if (details.reason === 'install') { 21 | await Runtime._reset(); 22 | } 23 | }, 24 | }, 25 | 26 | /** 27 | * Creates group tabs for groups that have no group tabs 28 | * 29 | * @returns {Promise} 30 | */ 31 | _recreateMissingGroupTabs: async function() { 32 | const windows = await Windows.getAll(); 33 | for (const window of windows) { 34 | const groups = await Windows.getAllGroupsIn(window.id); 35 | for (const group of groups) { 36 | const groupTab = await Groups.groupTab.get(group, window.id); 37 | if (groupTab != null) { continue; } 38 | 39 | // The group has no group tab, create a new one 40 | Communication.removeGroup(group); // If there is a cached port for the group tab, don't use it 41 | const span = await Tabs.getGroupIndexSpan(group, window.id, false); 42 | if (span != null) { 43 | await Groups.groupTab.create(group, window.id, span.min, false); 44 | await Communication.send.errors(group, ['Group tab had to be restored, some settings may have been lost. Due to how Firefox seems to work (currently), this seems to be expected behavior when the plugin updates. If you see this error under any other circumstances, please leave a comment here']); 45 | } 46 | } 47 | 48 | await Groups.collapse.allExceptCurrent(window.id); 49 | } 50 | }, 51 | 52 | /** 53 | * Resets all the tab values and hidden statuses 54 | * 55 | * @returns {Promise} 56 | */ 57 | _reset: async function() { 58 | const windows = await Windows.getAll(); 59 | for (const window of windows) { 60 | const tabs = await Windows.getAllTabsIn(window.id); 61 | for (const tab of tabs) { 62 | await Tabs.value.remove.all(tab.id); 63 | await Tabs.show(tab.id); 64 | } 65 | } 66 | }, 67 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Tab grouper 2 | Groups tabs in Firefox the same way you can do in chromium based browsers, replicated in the best way Firefox can currently manage. 3 | The plugin allows you to group tabs together, and hide tabs that are not in the currently active group (if any). 4 | 5 | ## Get 6 | ### Manual download 7 | (This isn't currently possible, but will be soon, so I'm leaving this part in). 8 | Download the .xpi file from the latest [release](https://github.com/TheNamlessGuy/browser-tab-grouper/releases). 9 | Drag and drop the file into your Firefox instance to install it. 10 | 11 | ### From Firefox AMO 12 | The download page is [here](https://addons.mozilla.org/firefox/addon/tab-grouper/). 13 | 14 | ## Usage 15 | After installing the plugin, you start by simply right clicking on a tab and selecting "Add to new group" from the Tab grouper interface: 16 | ![An image showing what the context menu options for adding a new group looks like](docs/images/add-to-new-group.png) 17 | 18 | This will open a "group tab": 19 | ![The contents of a group tab](docs/images/group-tab.png) 20 | 21 | Clicking on the tabs listed in the group will get to you to that tab. Closing the group tab itself will get rid of the group entirely. 22 | Moving to a tab outside of the group will hide all the tabs within the group, aside from the group tab itself: 23 | ![A gif showing that moving outside of the group tab will hide the tabs within the group](docs/images/tab-grouping-functionality.gif) 24 | 25 | The first time you move outside of a group, you will most likely get a popup like this: 26 | ![An image showing the hidden tabs warning Firefox gives off](docs/images/hidden-tabs-warning.png) 27 | It's of course up to you, but I recommend pressing the "Keep tabs hidden" option, or this plugin won't work as intended. 28 | 29 | Adding another tab to the group is as simple as right clicking on it and selecting "Add to group ": 30 | ![An image showing what the context menu options for adding a tab to an existing group looks like](docs/images/add-to-existing-group.png) 31 | 32 | Similarly, you can remove a tab from a group (or move it to another group) by right clicking on the tab in the group and selecting the appropriate option: 33 | ![An image showing what the context menu options for removing and moving a tab from groups looks like](docs/images/remove-and-move-group-options.png) 34 | 35 | There are a few more options, such as: 36 | * Renaming a group - Just edit the name on the group tab page 37 | * Tabs opened from within the group should be kept in the group - Check the checkbox on the group tab page 38 | 39 | ## Future plans and known issues 40 | See [this issue](https://github.com/TheNamlessGuy/browser-tab-grouper/issues/1). 41 | 42 | ## Cross-hosted 43 | This repository is hosted both on [GitHub](https://github.com/TheNamlessGuy/browser-tab-grouper) and [Codeberg](https://codeberg.org/TheNamlessGuy/browser-tab-grouper). 44 | -------------------------------------------------------------------------------- /src/group-tab/index.css: -------------------------------------------------------------------------------- 1 | .hidden { display: none !important; } 2 | 3 | html, body { 4 | margin: 0; 5 | padding: 0; 6 | min-height: 100vh; 7 | background-color: var(--background-color); 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | } 12 | 13 | :root { 14 | --content-container-min-height: 50vh; 15 | --content-container-width: 65vw; 16 | } 17 | 18 | body.theme-dark { 19 | --background-color: #000033; 20 | --foreground-color: #6666FF; 21 | --border-color: #333399; 22 | --text-color: #CCCCCC; 23 | --error-color: #CC3300; 24 | 25 | --button-border-color: var(--border-color); 26 | --button-red-border-color: #CC0000; 27 | 28 | --button-hovered-border-color: #3333FF; 29 | --button-red-hovered-border-color: #FF3300; 30 | --button-hovered-text-color: #FFFFFF; 31 | --button-red-hovered-text-color: var(--button-hovered-text-color); 32 | 33 | --button-active-border-color: #333333; 34 | --button-red-active-border-color: #CC0033; 35 | --button-active-text-color: #AAAAAA; 36 | --button-red-active-text-color: var(--button-active-text-color); 37 | } 38 | 39 | #error-container { 40 | position: absolute; 41 | top: 0px; 42 | margin: 5px; 43 | z-index: 9001; /* hehe */ 44 | } 45 | 46 | #content-container { 47 | width: var(--content-container-width); 48 | min-height: var(--content-container-min-height); 49 | border: 1px solid var(--border-color); 50 | border-radius: 5px; 51 | color: var(--text-color); 52 | font-family: serif; 53 | } 54 | 55 | #active-content, 56 | #inactive-content { 57 | display: flex; 58 | flex-direction: column; 59 | width: 100%; 60 | height: 100%; 61 | min-height: var(--content-container-min-height); 62 | } 63 | #inactive-content { 64 | justify-content: center; 65 | align-items: center; 66 | font-size: 135%; 67 | } 68 | 69 | #name-input-container { 70 | display: flex; 71 | justify-content: space-around; 72 | padding-top: 15px; 73 | } 74 | 75 | input { 76 | border: 1px solid var(--border-color); 77 | border-radius: 5px; 78 | background-color: var(--background-color); 79 | outline: none; 80 | color: inherit; 81 | text-align: center; 82 | font-family: monospace; 83 | padding: 5px; 84 | } 85 | input#name-input { font-size: 250%; } 86 | 87 | fieldset { 88 | margin: 0 15px; 89 | border: 2px solid var(--border-color); 90 | border-radius: 5px; 91 | } 92 | fieldset > legend { font-size: 120%; } 93 | 94 | #tab-fieldset { 95 | flex-grow: 1; 96 | margin-bottom: 15px; 97 | } 98 | 99 | button { 100 | border: 1px solid var(--button-border-color); 101 | border-radius: 5px; 102 | color: inherit; 103 | background-color: var(--background-color); 104 | cursor: pointer; 105 | } 106 | button:hover { 107 | border-color: var(--button-hovered-border-color); 108 | color: var(--button-hovered-text-color); 109 | } 110 | button:active { 111 | border-color: var(--button-active-border-color); 112 | color: var(--button-active-text-color); 113 | } 114 | button.red { border-color: var(--button-red-border-color); } 115 | button.red:hover { 116 | border-color: var(--button-red-hovered-border-color); 117 | color: var(--button-red-hovered-text-color); 118 | } 119 | button.red:active { 120 | border-color: var(--button-red-active-border-color); 121 | color: var(--button-red-active-text-color); 122 | } 123 | 124 | c-tab::part(body) { border-bottom: 1px solid var(--border-color); } 125 | c-tab:last-child::part(body) { border-bottom: none; } -------------------------------------------------------------------------------- /src/menus.js: -------------------------------------------------------------------------------- 1 | /** OnClickData 2 | * @typedef {object} OnClickData https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/menus/OnClickData 3 | * @property {string} menuItemId 4 | * @property {number} button 5 | * @property {('Alt'|'Command'|'Ctrl'|'MacCtrl'|'Shift')[]} modifiers 6 | */ 7 | 8 | /** MenuGroupVerb 9 | * @typedef {'Add'|'Move'|'Remove'} MenuGroupVerb 10 | */ 11 | 12 | const Menus = { 13 | /** 14 | * @returns {Promise} 15 | */ 16 | init: async function() { 17 | await browser.menus.removeAll(); 18 | 19 | if (!browser.menus.onShown.hasListener(Menus._on.show)) { 20 | browser.menus.onShown.addListener(Menus._on.show); 21 | } 22 | 23 | if (!browser.menus.onClicked.hasListener(Menus._on.clicked)) { 24 | browser.menus.onClicked.addListener(Menus._on.clicked); 25 | } 26 | 27 | browser.menus.create({ 28 | id: Menus._ids.root, 29 | title: 'Add to group', 30 | contexts: ['tab'], 31 | icons: { 32 | '48': 'res/icons/48.png', 33 | '32': 'res/icons/32.png', 34 | '16': 'res/icons/16.png', 35 | }, 36 | }); 37 | 38 | browser.menus.create({ 39 | id: Menus._ids.openCollapse, 40 | title: 'Open/collapse group', 41 | contexts: ['tab'], 42 | icons: { 43 | '48': 'res/icons/48.png', 44 | '32': 'res/icons/32.png', 45 | '16': 'res/icons/16.png', 46 | }, 47 | }); 48 | 49 | browser.menus.create({ 50 | id: Menus._ids.addToNewGroup, 51 | parentId: Menus._ids.root, 52 | title: 'Add to new group', 53 | visible: false, 54 | }); 55 | 56 | browser.menus.create({ 57 | id: Menus._ids.noActionsAvailable, 58 | parentId: Menus._ids.root, 59 | title: 'No actions available', 60 | visible: false, 61 | enabled: false, 62 | }); 63 | 64 | browser.menus.create({ 65 | id: Menus._ids.separator, 66 | parentId: Menus._ids.root, 67 | type: 'separator', 68 | }); 69 | 70 | const groups = await Groups.getAll(); 71 | for (const group of groups) { 72 | await Menus.addGroup(group); 73 | } 74 | }, 75 | 76 | /** 77 | * @param {string} group 78 | * @returns {Promise} 79 | */ 80 | addGroup: async function(group) { 81 | await browser.menus.update(Menus._ids.separator, {visible: true}); 82 | 83 | browser.menus.create({ 84 | id: Menus._ids.group(group), 85 | parentId: Menus._ids.root, 86 | title: Menus._groupTitle(group, 'Add'), 87 | }); 88 | }, 89 | 90 | /** 91 | * @param {string} oldName 92 | * @param {string} newName 93 | * @returns {Promise} 94 | */ 95 | renameGroup: async function(oldName, newName) { 96 | await Menus.removeGroup(oldName); 97 | await Menus.addGroup(newName); 98 | }, 99 | 100 | /** 101 | * @param {string} group 102 | * @returns {Promise} 103 | */ 104 | removeGroup: async function(group) { 105 | await browser.menus.remove(Menus._ids.group(group)); 106 | 107 | const groups = await Groups.getAll(); 108 | await browser.menus.update(Menus._ids.separator, {visible: groups.length > 0}); 109 | }, 110 | 111 | /** 112 | * If the user has a selection of tabs and clicks on a tab within that selection, return all selected tabs 113 | * If not, return the clicked tab. 114 | * 115 | * @param {BrowserTab} clickedTab 116 | * @returns {Promise} 117 | */ 118 | _getRelevantTabs: async function(clickedTab) { 119 | let selected = await browser.tabs.query({windowId: clickedTab.windowId, highlighted: true}); 120 | if (selected.map(tab => tab.id).includes(clickedTab.id)) { 121 | return selected.sort((a, b) => a.index - b.index); 122 | } 123 | 124 | return [clickedTab]; 125 | }, 126 | 127 | _ids: { 128 | root: 'root', 129 | openCollapse: 'open-collapse', 130 | addToNewGroup: 'add-to-new-group', 131 | noActionsAvailable: 'no-actions-available', 132 | separator: 'separator', 133 | groupPrefix: 'group--', 134 | 135 | /** 136 | * @param {string} group 137 | * @returns {string} 138 | */ 139 | group: (group) => `${Menus._ids.groupPrefix}${group}`, 140 | 141 | /** 142 | * @param {string} id 143 | * @returns {string} 144 | */ 145 | extractGroup: (id) => id.substring(Menus._ids.groupPrefix.length), 146 | }, 147 | 148 | _on: { 149 | /** 150 | * @param {OnClickData} info 151 | * @param {BrowserTab} tab 152 | * @returns {Promise} 153 | */ 154 | show: async function(info, tab) { 155 | const group = await Tabs.value.get.group(tab.id); 156 | const isGroupTab = await Tabs.value.get.isGroupTab(tab.id); 157 | const groups = await Groups.getAll(tab.incognito); 158 | const otherGroups = await Groups.getAll(!tab.incognito); 159 | 160 | if (group == null) { // The clicked tab isn't part of any group 161 | await browser.menus.update(Menus._ids.openCollapse, {visible: false}); 162 | await browser.menus.update(Menus._ids.noActionsAvailable, {visible: false}); 163 | await browser.menus.update(Menus._ids.addToNewGroup, {visible: true}); 164 | await browser.menus.update(Menus._ids.separator, {visible: groups.length > 0}); 165 | await browser.menus.update(Menus._ids.root, {title: 'Add to group', visible: true}); 166 | 167 | for (const g of groups) { 168 | await Menus._showGroup(g, 'Add'); 169 | } 170 | 171 | for (const g of otherGroups) { 172 | await Menus._showGroup(g, false); 173 | } 174 | } else if (isGroupTab) { // The clicked tab is a group tab 175 | await browser.menus.update(Menus._ids.root, {visible: false}); 176 | const automaticallyOpenCollapse = await Tabs.value.get.automaticallyOpenCollapse(tab.id); 177 | await browser.menus.update(Menus._ids.openCollapse, {visible: !automaticallyOpenCollapse}); 178 | } else { // The clicked tab is part of a group, and is a regular tab 179 | await browser.menus.update(Menus._ids.openCollapse, {visible: false}); 180 | await browser.menus.update(Menus._ids.noActionsAvailable, {visible: false}); 181 | await browser.menus.update(Menus._ids.addToNewGroup, {visible: true}); 182 | await browser.menus.update(Menus._ids.separator, {visible: groups.length > 0}); 183 | await browser.menus.update(Menus._ids.root, {title: 'Change grouping', visible: true}); 184 | 185 | for (const g of groups) { 186 | if (g === group) { 187 | await Menus._showGroup(g, 'Remove'); 188 | } else { 189 | await Menus._showGroup(g, 'Move'); 190 | } 191 | } 192 | 193 | for (const g of otherGroups) { 194 | await Menus._showGroup(g, false); 195 | } 196 | } 197 | 198 | await browser.menus.refresh(); 199 | }, 200 | 201 | /** 202 | * @param {string} group 203 | * @param {BrowserTab} clickedTab 204 | * @returns {Promise} 205 | */ 206 | groupAction: async function(group, clickedTab) { 207 | const tabGroup = await Tabs.value.get.group(clickedTab.id); 208 | const tabs = await Menus._getRelevantTabs(clickedTab); 209 | 210 | if (tabGroup === group) { // verb = 'Remove' 211 | for (const tab of tabs) { 212 | await Groups.removeTabFrom(group, tab.id); 213 | await Groups.collapse.allExceptCurrent(tab.windowId); 214 | } 215 | } else { // verb = 'Add' || 'Move' 216 | for (const tab of tabs) { 217 | await Groups.addTabTo(group, tab); 218 | } 219 | } 220 | }, 221 | 222 | /** 223 | * @param {OnClickData} info 224 | * @param {BrowserTab} clickedTab 225 | * @returns {Promise} 226 | */ 227 | clicked: async function(info, clickedTab) { 228 | if (info.menuItemId === Menus._ids.addToNewGroup) { 229 | Menus._on.addToNewGroup(clickedTab); 230 | } else if (info.menuItemId.startsWith(Menus._ids.groupPrefix)) { 231 | Menus._on.groupAction(Menus._ids.extractGroup(info.menuItemId), clickedTab); 232 | } else if (info.menuItemId === Menus._ids.openCollapse) { 233 | Menus._on.onOpenCollapseToggle(clickedTab); 234 | } 235 | }, 236 | 237 | /** 238 | * @param {BrowserTab} clickedTab 239 | * @returns {Promise} 240 | */ 241 | addToNewGroup: async function(clickedTab) { 242 | const tabs = await Menus._getRelevantTabs(clickedTab); 243 | 244 | const group = await Groups.add(null, tabs[0].windowId, tabs[0].index); 245 | for (const tab of tabs) { 246 | await Groups.addTabTo(group, tab); 247 | } 248 | }, 249 | 250 | /** 251 | * @param {BrowserTab} clickedTab 252 | * @returns {Promise} 253 | */ 254 | onOpenCollapseToggle: async function(clickedTab) { 255 | const group = await Tabs.value.get.group(clickedTab.id); 256 | await Groups.open.toggle(group, clickedTab.windowId); 257 | }, 258 | }, 259 | 260 | /** 261 | * @param {string} group 262 | * @param {MenuGroupVerb} verb 263 | * @returns {string} 264 | */ 265 | _groupTitle: function(group, verb) { 266 | const prepositions = {'Add': 'to', 'Move': 'to', 'Remove': 'from'}; 267 | return `${verb} ${prepositions[verb]} group '${group}'`; 268 | }, 269 | 270 | /** 271 | * @param {string} group 272 | * @param {MenuGroupVerb|false} verb 273 | * @returns {Promise} 274 | */ 275 | _showGroup: async function(group, verb) { 276 | if (verb === false) { 277 | await browser.menus.update(Menus._ids.group(group), {visible: false}); 278 | } else { 279 | await browser.menus.update(Menus._ids.group(group), { 280 | visible: true, 281 | title: Menus._groupTitle(group, verb), 282 | }); 283 | } 284 | }, 285 | }; -------------------------------------------------------------------------------- /src/communication.js: -------------------------------------------------------------------------------- 1 | /** BrowserPort 2 | * @typedef {object} BrowserPort 3 | * @property {string} name 4 | * @property {(Object) => void} postMessage 5 | * @property {BrowserPortOnMessage} onMessage 6 | */ 7 | 8 | /** BrowserPortOnMessage 9 | * @typedef {object} BrowserPortOnMessage 10 | * @property {(callback: (msg: any) => void)} addListener 11 | */ 12 | 13 | /** GroupTabOpts 14 | * @typedef {object} GroupTabOpts 15 | * @property {boolean} shouldKeepOpenedTabs 16 | * @property {boolean} promptOnClose 17 | * @property {boolean} rememberLastActiveTab 18 | * @property {boolean} automaticallyOpenCollapse 19 | * @property {string|null} iconColor 20 | * @property {string|null} customIconURL 21 | */ 22 | 23 | /** InitMessage 24 | * @typedef {object} InitMessage 25 | * @property {BrowserTab[]} tabs 26 | * @property {GroupTabOpts} opts 27 | */ 28 | 29 | /** RenameGroupMessage 30 | * @typedef {object} RenameGroupMessage 31 | * @property {string} newName 32 | */ 33 | 34 | /** AddTabMessage 35 | * @typedef {object} AddTabMessage 36 | * @property {BrowserTab} tab 37 | */ 38 | 39 | /** UpdateTabMessage 40 | * @typedef {object} UpdateTabMessage 41 | * @property {BrowserTab} tab 42 | */ 43 | 44 | /** SortTabsMessage 45 | * @typedef {object} SortTabsMessage 46 | * @property {BrowserTab[]} tabs 47 | */ 48 | 49 | /** RemoveTabMessage 50 | * @typedef {object} RemoveTabMessage 51 | * @property {number} tabID 52 | */ 53 | 54 | /** ErrorsMessage 55 | * @typedef {object} ErrorsMessage 56 | * @property {string[]} errors 57 | */ 58 | 59 | /** SetIconColorMessage 60 | * @typedef {object} SetIconColorMessage 61 | * @property {string} hex 62 | */ 63 | 64 | const Communication = { 65 | /** 66 | * @type {Object.} 67 | */ 68 | _ports: {}, 69 | /** 70 | * @type {Object. void>]} 71 | */ 72 | _onPortGet: {}, 73 | 74 | /** 75 | * @param {string} group 76 | * @returns {Promise} 77 | */ 78 | _getPort: function(group) { 79 | return new Promise((resolve) => { 80 | if (Communication._ports[group] != null) { 81 | resolve(Communication._ports[group]); 82 | return; 83 | } 84 | 85 | if (group in Communication._onPortGet) { 86 | Communication._onPortGet[group].push(resolve); 87 | } else { 88 | Communication._onPortGet[group] = [resolve]; 89 | } 90 | }); 91 | }, 92 | 93 | /** 94 | * @returns {void} 95 | */ 96 | init: function() { 97 | browser.runtime.onConnect.addListener(Communication._on.connect); 98 | }, 99 | 100 | /** 101 | * @param {string} oldName 102 | * @param {string} newName 103 | * @returns {void} 104 | */ 105 | renameGroup: function(oldName, newName) { 106 | const port = Communication._ports[oldName]; 107 | port.name = newName; 108 | delete Communication._ports[oldName]; 109 | Communication._ports[newName] = port; 110 | }, 111 | 112 | /** 113 | * @param {string} group 114 | */ 115 | removeGroup: function(group) { 116 | delete Communication._ports[group]; 117 | }, 118 | 119 | send: { 120 | /** 121 | * @param {string} group 122 | * @param {string} action 123 | * @param {Record} data 124 | * @returns {void} 125 | */ 126 | _data: async function(group, action, data) { 127 | const port = await Communication._getPort(group); 128 | if (port != null) { 129 | port.postMessage({action: action, ...JSON.parse(JSON.stringify(data))}); 130 | } 131 | }, 132 | 133 | /** 134 | * @param {string} group 135 | * @param {InitMessage} data 136 | * @returns {void} 137 | */ 138 | init: function(group, data) { 139 | Communication.send._data(group, 'init', data); 140 | }, 141 | 142 | /** 143 | * @param {string} group 144 | * @param {string} newName 145 | * @returns {void} 146 | */ 147 | renameGroup: function(group, newName) { 148 | Communication.send._data(group, 'rename-group', {newName}); 149 | }, 150 | 151 | /** 152 | * @param {string} group 153 | * @param {BrowserTab} data 154 | * @returns {void} 155 | */ 156 | addTab: function(group, tab) { 157 | Communication.send._data(group, 'add-tab', {tab}); 158 | }, 159 | 160 | /** 161 | * @param {string} group 162 | * @param {BrowserTab} data 163 | * @returns {void} 164 | */ 165 | updateTab: function(group, tab) { 166 | Communication.send._data(group, 'update-tab', {tab}); 167 | }, 168 | 169 | /** 170 | * @param {string} group 171 | * @param {BrowserTab[]} tabs 172 | * @returns {void} 173 | */ 174 | sortTabs: function(group, tabs) { 175 | Communication.send._data(group, 'sort-tabs', {tabs}); 176 | }, 177 | 178 | /** 179 | * @param {string} group 180 | * @param {number} tabID 181 | * @returns {void} 182 | */ 183 | removeTab: function(group, tabID) { 184 | Communication.send._data(group, 'remove-tab', {tabID}); 185 | }, 186 | 187 | /** 188 | * @param {string} group 189 | * @param {string|string[]} errors 190 | * @returns {void} 191 | */ 192 | errors: function(group, errors) { 193 | Communication.send._data(group, 'errors', {errors: Array.isArray(errors) ? errors : [errors]}); 194 | }, 195 | }, 196 | 197 | _on: { 198 | /** 199 | * @param {BrowserPort} port 200 | * @returns {void} 201 | */ 202 | connect: function(port) { 203 | Communication._ports[port.name] = port; 204 | port.onMessage.addListener((msg) => { 205 | if (msg.action in Communication._on.map) { 206 | Communication._on.map[msg.action](port.name, msg); 207 | } else { 208 | console.error('Unknown action gotten by Communication', {group: port.name, msg}); 209 | } 210 | }); 211 | 212 | if (port.name in Communication._onPortGet) { 213 | for (const resolve of Communication._onPortGet[port.name]) { resolve(port); } 214 | delete Communication._onPortGet[port.name]; 215 | } 216 | }, 217 | 218 | map: { 219 | /** 220 | * @param {RenameGroupMessage} msg 221 | * @returns {Promise} 222 | */ 223 | 'rename-group': async function(group, msg) { 224 | const errors = await Groups.checkNameValidity(msg.newName); 225 | if (errors.length === 0) { 226 | await Groups.rename(group, msg.newName); 227 | } else { 228 | await Communication.send.errors(group, errors); 229 | await Communication.send.renameGroup(group, group); // Reset the name 230 | } 231 | }, 232 | 233 | /** 234 | * @param {string} group 235 | * @param {SetIconColorMessage} msg 236 | */ 237 | 'set-icon-color': async function(group, msg) { 238 | const groupTab = await Groups.groupTab.get(group); 239 | await Tabs.value.set.iconColor(groupTab.id, msg.hex); 240 | }, 241 | 242 | /** 243 | * @param {string} group 244 | * @param {*} msg 245 | * @returns {Promise} 246 | */ 247 | 'highlight-tabs': async function(group, msg) { 248 | await Tabs.highlightTabsInGroup(group); 249 | }, 250 | 251 | /** 252 | * @param {string} group 253 | * @param {{value: boolean}} msg 254 | */ 255 | 'set-opt--should-keep-opened-tabs': async function(group, msg) { 256 | const groupTab = await Groups.groupTab.get(group); 257 | await Tabs.value.set.shouldKeepOpenedTabs(groupTab.id, msg.value); 258 | }, 259 | 260 | /** 261 | * @param {string} group 262 | * @param {{value: boolean}} msg 263 | */ 264 | 'set-opt--prompt-on-close': async function(group, msg) { 265 | const groupTab = await Groups.groupTab.get(group); 266 | await Tabs.value.set.promptOnClose(groupTab.id, msg.value); 267 | }, 268 | 269 | /** 270 | * @param {string} group 271 | * @param {{value: boolean}} msg 272 | */ 273 | 'set-opt--remember-last-active-tab': async function(group, msg) { 274 | const groupTab = await Groups.groupTab.get(group); 275 | await Tabs.value.set.rememberLastActiveTab(groupTab.id, msg.value); 276 | }, 277 | 278 | /** 279 | * @param {string} group 280 | * @param {{value: boolean}} msg 281 | */ 282 | 'set-opt--automatically-open-collapse': async function(group, msg) { 283 | const groupTab = await Groups.groupTab.get(group); 284 | await Tabs.value.set.automaticallyOpenCollapse(groupTab.id, msg.value); 285 | }, 286 | 287 | /** 288 | * @param {string} group 289 | * @param {RemoveTabMessage} msg 290 | * @returns {Promise} 291 | */ 292 | 'remove-tab': async function(group, msg) { 293 | await Groups.removeTabFrom(group, msg.tabID); 294 | }, 295 | 296 | /** 297 | * @param {string} group 298 | * @param {{tabID: number}} msg 299 | * @returns {Promise} 300 | */ 301 | 'swap-to-tab': async function(group, msg) { 302 | await browser.tabs.update(msg.tabID, {active: true}); 303 | }, 304 | 305 | /** 306 | * @param {string} group 307 | * @param {{dataURL: string}} msg 308 | * @returns {Promise} 309 | */ 310 | 'set-custom-icon': async function(group, msg) { 311 | const groupTab = await Groups.groupTab.get(group); 312 | await Tabs.value.set.customIconURL(groupTab.id, msg.dataURL); 313 | }, 314 | 315 | /** 316 | * @param {string} group 317 | * @param {{}} msg 318 | * @returns {Promise} 319 | */ 320 | 'toggle-open-collapse': async function(group, msg) { 321 | await Groups.open.toggle(group); 322 | }, 323 | }, 324 | }, 325 | }; -------------------------------------------------------------------------------- /src/groups.js: -------------------------------------------------------------------------------- 1 | const Groups = { 2 | /** 3 | * When moving a tab from one window to another, it loses all its session data. 4 | * In order for us to be able to tell the group that the tab is no longer taking part of it, we need to cache the data. 5 | * @see Tabs._on.attached. 6 | * 7 | * @type {Record} Maps tab ID to the group the tab is part of. 8 | */ 9 | groupedTabCache: {}, 10 | 11 | /** 12 | * @returns {Promise} 13 | */ 14 | init: async function() { 15 | const windows = await Windows.getAll(); 16 | for (const window of windows) { 17 | const tabs = await Windows.getAllTabsIn(window.id); 18 | for (const tab of tabs) { 19 | const group = await Tabs.value.get.group(tab.id); 20 | const isGroupTab = await Tabs.value.get.isGroupTab(tab.id); 21 | 22 | if (isGroupTab) { 23 | await Groups.groupTab.init(tab.id, group, tab.windowId); 24 | } else { 25 | Groups.groupedTabCache[tab.id] = group; 26 | } 27 | } 28 | } 29 | }, 30 | 31 | /** 32 | * @param {boolean|null} incognito When true, only return groups that are in incognito mode. When false, return groups that aren't incognito. When null, return all groups regardless 33 | * @returns {Promise} 34 | */ 35 | getAll: async function(incognito = null) { 36 | const retval = []; 37 | 38 | const windows = await Windows.getAll(incognito); 39 | for (const window of windows) { 40 | // Since group names are unique, and Windows.getAllGroupsIn returns a unique list, we don't need to do uniqueness checks here 41 | retval.push(...(await Windows.getAllGroupsIn(window.id))); 42 | } 43 | 44 | return retval; 45 | }, 46 | 47 | /** 48 | * @param {string|null} group 49 | * @param {number} windowID 50 | * @param {number} index 51 | * @returns {Promise} The name of the created group 52 | */ 53 | add: async function(group, windowID, index) { 54 | group = group ?? await Groups._getNextFreeName(); 55 | await Groups.groupTab.create(group, windowID, index); 56 | await Menus.addGroup(group); 57 | await Groups.collapse.allExcept(group, windowID); 58 | return group; 59 | }, 60 | 61 | /** 62 | * @param {string} name 63 | * @returns {string[]} 64 | */ 65 | checkNameValidity: async function(name) { 66 | const errors = []; 67 | 68 | if (name.length === 0) { 69 | errors.push('Name cannot be empty'); 70 | } 71 | 72 | const windowID = await Windows.getIDForGroup(name); 73 | if (windowID != null) { 74 | errors.push(`The group name '${name}' is already taken`); 75 | } 76 | 77 | return errors; 78 | }, 79 | 80 | /** 81 | * @param {string} oldName 82 | * @param {string} newName 83 | * @returns {Promise} 84 | */ 85 | rename: async function(oldName, newName) { 86 | await Communication.renameGroup(oldName, newName); 87 | await Menus.renameGroup(oldName, newName); 88 | 89 | const windowID = await Windows.getIDForGroup(oldName); 90 | const tabs = await Tabs.getTabsInGroup(oldName, windowID, true); 91 | for (const tab of tabs) { 92 | await Tabs.value.set.group(tab.id, newName); 93 | Groups.groupedTabCache[tab.id] = newName; 94 | 95 | const isGroupTab = await Tabs.value.get.isGroupTab(tab.id); 96 | if (isGroupTab) { 97 | Groups.groupTab.cache[tab.id] = newName; 98 | } 99 | } 100 | 101 | Communication.send.renameGroup(newName, newName); 102 | }, 103 | 104 | /** 105 | * @param {string} group 106 | * @param {number|null} [windowID] 107 | * @returns {Promise} 108 | */ 109 | remove: async function(group, windowID = null) { 110 | await Menus.removeGroup(group); 111 | await Communication.removeGroup(group); 112 | 113 | windowID = windowID ?? await Windows.getIDForGroup(group); 114 | const activeGroup = await Windows.getCurrentGroupIn(windowID); 115 | 116 | const tabs = await Tabs.getTabsInGroup(group, windowID); 117 | for (const tab of tabs) { 118 | await Groups.removeTabFrom(group, tab.id); 119 | } 120 | 121 | if (activeGroup === group) { 122 | await Groups.collapse.allExcept(null, windowID); 123 | } else { 124 | await Groups.collapse.allExceptCurrent(windowID); 125 | } 126 | }, 127 | 128 | /** 129 | * @param {string} group 130 | * @param {BrowserTab} tab 131 | * @returns {Promise} 132 | */ 133 | addTabTo: async function(group, tab) { 134 | const isGroupTab = await Tabs.value.get.isGroupTab(tab.id); 135 | if (isGroupTab) { return; } 136 | 137 | const currentGroup = await Tabs.value.get.group(tab.id); 138 | if (currentGroup === group) { 139 | return; 140 | } else if (currentGroup != null) { 141 | await Groups.removeTabFrom(currentGroup, tab.id); 142 | } 143 | 144 | const windowID = await Windows.getIDForGroup(group); 145 | await Tabs.value.set.group(tab.id, group); 146 | await Tabs.move.intoGroup(tab.id); 147 | Communication.send.addTab(group, tab); 148 | await Groups.collapse.allExceptCurrent(windowID); 149 | }, 150 | 151 | /** 152 | * @param {string} group 153 | * @param {number} tabID 154 | * @returns {Promise} 155 | */ 156 | removeTabFrom: async function(group, tabID) { 157 | if (Groups.lastActiveTab._cache[group] === tabID) { 158 | delete Groups.lastActiveTab._cache[group]; 159 | } 160 | 161 | Communication.send.removeTab(group, tabID); 162 | await Tabs.value.remove.group(tabID); 163 | await Tabs.show(tabID); 164 | await Tabs.move.outOfOtherGroups(tabID); 165 | }, 166 | 167 | open: { 168 | /** 169 | * @param {string} group 170 | * @param {number|null} windowID 171 | */ 172 | group: async function(group, windowID = null) { 173 | const tabs = await Tabs.getTabsInGroup(group, windowID); 174 | for (const tab of tabs) { 175 | await Tabs.show(tab.id); 176 | } 177 | }, 178 | 179 | /** 180 | * @param {string} group 181 | * @param {number|null} windowID 182 | * @returns {Promise} 183 | */ 184 | is: async function(group, windowID = null) { 185 | const tabs = await Tabs.getTabsInGroup(group, windowID); 186 | return !tabs[0].hidden; 187 | }, 188 | 189 | /** 190 | * @param {string} group 191 | * @param {number|null} windowID 192 | * @returns {Promise} 193 | */ 194 | toggle: async function(group, windowID = null) { 195 | const isOpen = await Groups.open.is(group, windowID); 196 | if (isOpen) { 197 | await Groups.collapse.group(group, windowID); 198 | } else { 199 | await Groups.open.group(group, windowID); 200 | } 201 | }, 202 | }, 203 | 204 | collapse: { 205 | /** 206 | * @param {string} group 207 | * @param {number|null} windowID 208 | */ 209 | group: async function(group, windowID = null) { 210 | const tabs = await Tabs.getTabsInGroup(group, windowID); 211 | for (const tab of tabs) { 212 | await Tabs.hide(tab.id); 213 | } 214 | }, 215 | 216 | /** 217 | * @param {string|null} except The name of the group to not collapse, if any 218 | * @param {number} windowID 219 | * @returns {Promise} 220 | */ 221 | allExcept: async function(except, windowID) { 222 | const tabs = await Windows.getAllTabsIn(windowID); 223 | for (const tab of tabs) { 224 | const isGroupTab = await Tabs.value.get.isGroupTab(tab.id); 225 | if (isGroupTab) { 226 | await Tabs.show(tab.id); 227 | continue; 228 | } 229 | 230 | const group = await Tabs.value.get.group(tab.id); 231 | if (group == null) { 232 | await Tabs.show(tab.id); 233 | continue; 234 | } 235 | 236 | const automaticallyOpenCollapse = await Tabs.value.getViaGroup.automaticallyOpenCollapse(group); 237 | if (!automaticallyOpenCollapse) { 238 | continue; // This group manages its own collapsing 239 | } 240 | 241 | if (group === except) { 242 | await Tabs.show(tab.id); 243 | } else { 244 | await Tabs.hide(tab.id); 245 | } 246 | } 247 | }, 248 | 249 | /** 250 | * @param {number} windowID 251 | * @returns {Promise} 252 | */ 253 | allExceptCurrent: async function(windowID) { 254 | await Groups.collapse.allExcept(await Windows.getCurrentGroupIn(windowID), windowID); 255 | }, 256 | }, 257 | 258 | lastActiveTab: { 259 | /** 260 | * @type {Record} Maps the group to the tab ID 261 | */ 262 | _cache: {}, // TODO: Once you can use tabs.query on session values, use session values instead 263 | 264 | /** 265 | * @param {string} group 266 | * @returns {Promise} The ID of the last active tab in the group, if any 267 | */ 268 | get: async function(group) { 269 | const shouldRemember = await Tabs.value.getViaGroup.rememberLastActiveTab(group); 270 | if (shouldRemember) { 271 | return Groups.lastActiveTab._cache[group] ?? null; 272 | } 273 | 274 | return null; 275 | }, 276 | 277 | /** 278 | * @param {string} group 279 | * @param {number} tabID 280 | * @returns {void} 281 | */ 282 | set: function(group, tabID) { 283 | Groups.lastActiveTab._cache[group] = tabID; 284 | }, 285 | 286 | /** 287 | * @param {string} group 288 | * @returns {void} 289 | */ 290 | remove: function(group) { 291 | delete Groups.lastActiveTab._cache[group]; 292 | }, 293 | }, 294 | 295 | groupTab: { 296 | /** 297 | * Since we can't access session data when a tab is removed (for some dumbfuck reason), 298 | * we need to cache which tabs are group tabs. 299 | * That way, we can un-group the grouped tabs if the group tab is removed. 300 | * Something similar happens when you move a group tab to a new window as well. 301 | * 302 | * @type {Record} Maps tab ID to the group 303 | */ 304 | cache: {}, 305 | 306 | /** 307 | * @param {string|null} group 308 | * @returns {string} 309 | */ 310 | getURL: function(group) { 311 | let url = '/src/group-tab/index.html'; 312 | if (group != null) { url += `?group=${encodeURIComponent(group)}`; } 313 | return browser.runtime.getURL(url); 314 | }, 315 | 316 | /** 317 | * @param {string} group 318 | * @param {number} windowID 319 | * @param {number} index 320 | * @param {boolean} active 321 | * @returns {Promise} 322 | */ 323 | create: async function(group, windowID, index, active = true) { 324 | const tab = await browser.tabs.create({ 325 | url: Groups.groupTab.getURL(group), 326 | index: index, 327 | active: active, 328 | windowId: windowID, 329 | }); 330 | 331 | await Groups.groupTab.init(tab.id, group, tab.windowId); 332 | return tab; 333 | }, 334 | 335 | /** 336 | * @param {number} tabID 337 | * @param {string} group 338 | * @param {number} windowID 339 | * @returns {Promise} 340 | */ 341 | init: async function(tabID, group, windowID) { 342 | Groups.groupTab.cache[tabID] = group; 343 | 344 | await Tabs.value.initialize.group(tabID, group); 345 | await Tabs.value.initialize.isGroupTab(tabID, true); 346 | await Tabs.value.initialize.shouldKeepOpenedTabs(tabID, false); 347 | await Tabs.value.initialize.iconColor(tabID, 'FFFFFF'); 348 | await Tabs.value.initialize.promptOnClose(tabID, false); 349 | await Tabs.value.initialize.rememberLastActiveTab(tabID, true); 350 | await Tabs.value.initialize.automaticallyOpenCollapse(tabID, true); 351 | 352 | await Groups.groupTab.update(group, windowID, tabID); 353 | }, 354 | 355 | /** 356 | * @param {string} group 357 | * @param {number|null} windowID 358 | * @param {number|null} tabID 359 | * @returns {Promise} 360 | */ 361 | update: async function(group, windowID = null, tabID = null) { 362 | windowID = windowID ?? await Windows.getIDForGroup(group); 363 | tabID = tabID ?? (await Groups.groupTab.get(group, windowID)).id; 364 | 365 | Communication.send.init(group, { 366 | tabs: await Tabs.getTabsInGroup(group, windowID), 367 | opts: { 368 | shouldKeepOpenedTabs: await Tabs.value.get.shouldKeepOpenedTabs(tabID), 369 | iconColor: await Tabs.value.get.iconColor(tabID), 370 | customIconURL: await Tabs.value.get.customIconURL(tabID), 371 | promptOnClose: await Tabs.value.get.promptOnClose(tabID), 372 | rememberLastActiveTab: await Tabs.value.get.rememberLastActiveTab(tabID), 373 | automaticallyOpenCollapse: await Tabs.value.get.automaticallyOpenCollapse(tabID), 374 | }, 375 | }); 376 | }, 377 | 378 | /** 379 | * @param {string} group 380 | * @param {number|null} windowID 381 | * @param {number|null} except 382 | * @returns {Promise} 383 | */ 384 | get: async function(group, windowID = null, except = null) { 385 | windowID = windowID ?? await Windows.getIDForGroup(group, except); 386 | 387 | const tabs = await Windows.getAllTabsIn(windowID); 388 | for (const tab of tabs) { 389 | if (except != null && tab.id === except) { continue; } 390 | 391 | const tabGroup = await Tabs.value.get.group(tab.id); 392 | const isGroupTab = await Tabs.value.get.isGroupTab(tab.id); 393 | if (isGroupTab && tabGroup === group) { 394 | return tab; 395 | } 396 | } 397 | 398 | return null; 399 | }, 400 | 401 | /** 402 | * @param {number} tabID 403 | * @returns {Promise} 404 | */ 405 | deinit: async function(tabID) { 406 | await Tabs.value.remove.all(tabID); 407 | delete Groups.groupTab.cache[tabID]; 408 | }, 409 | }, 410 | 411 | /** 412 | * @returns {Promise} 413 | */ 414 | _getNextFreeName: async function() { 415 | const groups = await Groups.getAll(); 416 | 417 | let i = 1; 418 | let retval = `Group ${i}`; 419 | while (groups.includes(retval)) { 420 | i += 1; 421 | retval = `Group ${i}`; 422 | } 423 | 424 | return retval; 425 | }, 426 | }; -------------------------------------------------------------------------------- /src/group-tab/index.js: -------------------------------------------------------------------------------- 1 | const BackgroundPage = { 2 | /** 3 | * @type {BrowserPort|null} 4 | */ 5 | _port: null, 6 | 7 | /** 8 | * @returns {void} 9 | */ 10 | init: function() { 11 | BackgroundPage._port = browser.runtime.connect({name: Group.get()}); 12 | BackgroundPage._port.onMessage.addListener(BackgroundPage._on.message); 13 | }, 14 | 15 | send: { 16 | /** 17 | * @param {string} action 18 | * @param {Record} extras 19 | * @returns {*} 20 | */ 21 | action: function(action, extras = {}) { 22 | Errors.clear(); 23 | BackgroundPage._port.postMessage({action: action, ...JSON.parse(JSON.stringify(extras))}); 24 | }, 25 | 26 | /** 27 | * @param {string} newName 28 | * @returns {void} 29 | */ 30 | rename: function(newName) { 31 | BackgroundPage.send.action('rename-group', {newName}); 32 | }, 33 | 34 | /** 35 | * @param {string} hex 36 | * @returns {void} 37 | */ 38 | setIconColor: function(hex) { 39 | BackgroundPage.send.action('set-icon-color', {hex}); 40 | }, 41 | 42 | /** 43 | * @returns {void} 44 | */ 45 | highlightTabs: function() { 46 | BackgroundPage.send.action('highlight-tabs'); 47 | }, 48 | 49 | /** 50 | * @param {boolean} value 51 | * @returns {void} 52 | */ 53 | setShouldKeepOpenedTabs: function(value) { 54 | BackgroundPage.send.action('set-opt--should-keep-opened-tabs', {value}); 55 | }, 56 | 57 | /** 58 | * @param {boolean} value 59 | * @returns {void} 60 | */ 61 | setPromptOnClose: function(value) { 62 | BackgroundPage.send.action('set-opt--prompt-on-close', {value}); 63 | }, 64 | 65 | /** 66 | * @param {boolean} value 67 | * @returns {void} 68 | */ 69 | rememberLastActiveTab: function(value) { 70 | BackgroundPage.send.action('set-opt--remember-last-active-tab', {value}); 71 | }, 72 | 73 | /** 74 | * @param {boolean} value 75 | * @returns {void} 76 | */ 77 | automaticallyOpenCollapse: function(value) { 78 | BackgroundPage.send.action('set-opt--automatically-open-collapse', {value}); 79 | }, 80 | 81 | /** 82 | * @param {number} tabID 83 | * @returns {void} 84 | */ 85 | removeTab: function(tabID) { 86 | BackgroundPage.send.action('remove-tab', {tabID}); 87 | }, 88 | 89 | /** 90 | * @param {number} tabID 91 | * @returns {void} 92 | */ 93 | swapToTab: function(tabID) { 94 | BackgroundPage.send.action('swap-to-tab', {tabID}); 95 | }, 96 | 97 | /** 98 | * @param {string} dataURL 99 | * @returns {void} 100 | */ 101 | setCustomIcon: function(dataURL) { 102 | BackgroundPage.send.action('set-custom-icon', {dataURL}); 103 | }, 104 | 105 | /** 106 | * @returns {void} 107 | */ 108 | toggleOpenCollapse: function() { 109 | BackgroundPage.send.action('toggle-open-collapse', {}); 110 | }, 111 | }, 112 | 113 | _on: { 114 | message: function(msg) { 115 | if (msg.action in Actions) { 116 | Actions[msg.action](msg); 117 | } else { 118 | console.error('Unknown action gotten by tab group', {group: Group.get(), msg}); 119 | } 120 | }, 121 | }, 122 | }; 123 | 124 | const Errors = { 125 | /** 126 | * @returns {void} 127 | */ 128 | clear: function() { 129 | const container = document.getElementById('error-container'); 130 | while (container.children.length > 0) { 131 | container.children[0].remove(); 132 | } 133 | 134 | Icon.update(); 135 | }, 136 | 137 | /** 138 | * @param {string} error 139 | * @param {boolean} updateIcon 140 | * @returns {void} 141 | */ 142 | add: function(error, updateIcon = true) { 143 | const element = document.createElement('c-error'); 144 | element.message = error; 145 | element.addEventListener('removed', function() { 146 | if (Errors.count() === 0) { 147 | Icon.update(); 148 | } 149 | }); 150 | document.getElementById('error-container').append(element); 151 | 152 | if (updateIcon) { 153 | Icon.update(); 154 | } 155 | }, 156 | 157 | /** 158 | * @param {string[]} errors 159 | * @returns {void} 160 | */ 161 | addAll: function(errors) { 162 | for (const error of errors) { 163 | Errors.add(error, false); 164 | } 165 | 166 | Icon.update(); 167 | }, 168 | 169 | /** 170 | * @returns {number} 171 | */ 172 | count: function() { 173 | return document.getElementById('error-container').children.length; 174 | }, 175 | }; 176 | 177 | const Group = { 178 | /** 179 | * @returns {string} 180 | */ 181 | get: function() { 182 | const group = new URL(window.location.href).searchParams.get('group'); 183 | return group == null ? null : decodeURIComponent(group); 184 | }, 185 | 186 | /** 187 | * @param {string} group 188 | * @param {boolean} [pushToBackend=true] 189 | * @param {boolean} [force=false] 190 | * @returns {void} 191 | */ 192 | set: function(group, pushToBackend = true, force = false) { 193 | if (!force && Group.get() === group) { return; } 194 | 195 | document.title = (!!group) ? group : 'No group name'; 196 | document.getElementById('name-input').value = group; 197 | Group.setInputWidth(); 198 | 199 | const url = new URL(window.location.href); 200 | url.searchParams.set('group', group); 201 | window.history.replaceState(null, null, url); 202 | 203 | if (pushToBackend) { 204 | BackgroundPage.send.rename(group); 205 | } 206 | }, 207 | 208 | /** 209 | * @returns {void} 210 | */ 211 | update: function() { 212 | const newValue = document.getElementById('name-input').value.trim(); 213 | const oldValue = Group.get(); 214 | 215 | if (newValue !== oldValue) { 216 | Group.set(newValue); 217 | } 218 | }, 219 | 220 | /** 221 | * @returns {void} 222 | */ 223 | setInputWidth: function() { 224 | const element = document.getElementById('name-input'); 225 | const value = element.value.length < 10 ? 10 : element.value.length; 226 | element.style.width = `${value}ch`; 227 | }, 228 | }; 229 | 230 | const Tabs = { 231 | /** 232 | * @param {number} tabID 233 | * @returns {string} 234 | */ 235 | _id: function(tabID) { 236 | return `tab--${tabID}`; 237 | }, 238 | 239 | /** 240 | * @param {BrowserTab} tab 241 | * @returns {void} 242 | */ 243 | add: function(tab) { 244 | const id = Tabs._id(tab.id); 245 | if (document.getElementById(id) != null) { // We're already displaying this tab 246 | return; 247 | } 248 | 249 | const element = document.createElement('c-tab'); 250 | element.id = id; 251 | element.setTabData(tab); 252 | element.addEventListener('remove-me', () => BackgroundPage.send.removeTab(tab.id)); 253 | element.addEventListener('swap-to-me', () => BackgroundPage.send.swapToTab(tab.id)); 254 | document.getElementById('tab-container').append(element); 255 | }, 256 | 257 | /** 258 | * @param {BrowserTab} tab 259 | * @returns {void} 260 | */ 261 | update: function(tab) { 262 | const element = document.getElementById(Tabs._id(tab.id)); 263 | if (element != null) { 264 | element.setTabData(tab); 265 | } 266 | }, 267 | 268 | /** 269 | * @param {number[]} order A list of tab IDs sorted in the correct order 270 | * @returns {void} 271 | */ 272 | sort: function(order) { 273 | const container = document.getElementById('tab-container'); 274 | for (const tabID of order) { 275 | container.append(document.getElementById(Tabs._id(tabID))); 276 | } 277 | }, 278 | 279 | /** 280 | * @param {number} tabID 281 | * @returns {void} 282 | */ 283 | remove: function(tabID) { 284 | const element = document.getElementById(Tabs._id(tabID)); 285 | if (element != null) { 286 | element.remove(); 287 | } 288 | }, 289 | 290 | /** 291 | * @returns {void} 292 | */ 293 | removeAll: function() { 294 | const container = document.getElementById('tab-container'); 295 | while (container.children.length > 0) { 296 | container.children[0].remove(); 297 | } 298 | }, 299 | }; 300 | 301 | const Actions = { 302 | /** 303 | * @param {ErrorsMessage} msg 304 | * @returns {void} 305 | */ 306 | 'errors': function(msg) { 307 | Errors.addAll(msg.errors); 308 | }, 309 | 310 | /** 311 | * @param {InitMessage} msg 312 | * @returns {void} 313 | */ 314 | 'init': function(msg) { 315 | document.getElementById('should-keep-opened-tabs').checked = msg.opts.shouldKeepOpenedTabs; 316 | document.getElementById('prompt-on-close').checked = msg.opts.promptOnClose; 317 | document.getElementById('remember-last-active-tab').checked = msg.opts.rememberLastActiveTab; 318 | document.getElementById('automatically-open-collapse').checked = msg.opts.automaticallyOpenCollapse; 319 | document.getElementById('open-collapse-group-btn').classList.toggle('hidden', msg.opts.automaticallyOpenCollapse); 320 | 321 | Tabs.removeAll(); 322 | for (const tab of msg.tabs) { 323 | Tabs.add(tab); 324 | } 325 | 326 | Icon._customIconURL = msg.opts.customIconURL; 327 | Icon.color.set(msg.opts.iconColor, false); 328 | Icon.update(); 329 | }, 330 | 331 | /** 332 | * @param {RenameGroupMessage} msg 333 | * @returns {void} 334 | */ 335 | 'rename-group': function(msg) { 336 | Group.set(msg.newName, false); 337 | }, 338 | 339 | /** 340 | * @param {AddTabMessage} msg 341 | * @returns {void} 342 | */ 343 | 'add-tab': function(msg) { 344 | Tabs.add(msg.tab); 345 | }, 346 | 347 | /** 348 | * @param {UpdateTabMessage} msg 349 | * @returns {void} 350 | */ 351 | 'update-tab': function(msg) { 352 | Tabs.update(msg.tab); 353 | }, 354 | 355 | /** 356 | * @param {SortTabsMessage} msg 357 | * @returns {void} 358 | */ 359 | 'sort-tabs': function(msg) { 360 | Tabs.sort(msg.tabs.map(tab => tab.id)); 361 | }, 362 | 363 | /** 364 | * @param {RemoveTabMessage} msg 365 | * @returns {void} 366 | */ 367 | 'remove-tab': function(msg) { 368 | Tabs.remove(msg.tabID); 369 | }, 370 | }; 371 | 372 | const Icon = { 373 | /** 374 | * @type {string|null} 375 | */ 376 | _customIconURL: null, 377 | 378 | /** 379 | * @returns {void} 380 | */ 381 | init: function() { 382 | const picker = document.getElementById('tab-icon-color'); 383 | picker.addEventListener('change', () => { 384 | Icon.color.set(Icon.color.get.hex(), true); 385 | Icon.update(); 386 | }); 387 | }, 388 | 389 | get: { 390 | /** 391 | * @param {string} url 392 | * @returns {Promise} 393 | */ 394 | _imageFromURL: function(url) { 395 | return new Promise((resolve) => { 396 | const img = document.createElement('img'); 397 | img.addEventListener('load', () => resolve(img)); 398 | img.src = url; 399 | }); 400 | }, 401 | 402 | /** 403 | * @returns {Promise} 404 | */ 405 | default: async function() { 406 | return Icon.get._imageFromURL('/res/icons/16.png'); 407 | }, 408 | 409 | /** 410 | * @returns {Promise} 411 | */ 412 | custom: async function() { 413 | if (Icon._customIconURL == null) { 414 | return null; 415 | } 416 | 417 | return Icon.get._imageFromURL(Icon._customIconURL); 418 | }, 419 | }, 420 | 421 | /** 422 | * @param {HTMLImageElement} img 423 | * @returns {void} 424 | */ 425 | _set: function(img) { 426 | document.getElementById('page-icon').href = img.src; 427 | }, 428 | 429 | /** 430 | * @returns {Promise} 431 | */ 432 | update: async function() { 433 | let img = await Icon.get.default(); 434 | img = Icon._tint(img); 435 | img = await Icon._overlayCustomIcon(img); 436 | img = Icon._overlayNotification(img); 437 | Icon._set(img); 438 | }, 439 | 440 | color: { 441 | get: { 442 | /** 443 | * @returns {string} 444 | */ 445 | hex: function() { 446 | return document.getElementById('tab-icon-color').value.substring(1); // Remove preceeding # 447 | }, 448 | 449 | /** 450 | * Thanks stack overflow :) 451 | * https://stackoverflow.com/a/11508164 452 | * 453 | * @returns {{r: number, g: number, b: number}} 454 | */ 455 | rgb: function() { 456 | const bigint = parseInt(Icon.color.get.hex(), 16); 457 | return { 458 | r: (bigint >> 16) & 255, 459 | g: (bigint >> 8) & 255, 460 | b: bigint & 255, 461 | }; 462 | }, 463 | }, 464 | 465 | /** 466 | * @param {string} hex 467 | * @param {boolean} push 468 | * @returns {void} 469 | */ 470 | set: function(hex, push) { 471 | hex = hex ?? 'FFFFFF'; 472 | document.getElementById('tab-icon-color').value = `#${hex}`; 473 | 474 | if (push) { 475 | BackgroundPage.send.setIconColor(hex); 476 | } 477 | }, 478 | }, 479 | 480 | /** 481 | * @param {HTMLImageElement} img 482 | * @returns {HTMLImageElement} 483 | */ 484 | _tint: function(img) { 485 | const color = Icon.color.get.rgb(); 486 | 487 | const canvas = document.createElement('canvas'); 488 | canvas.width = 16; 489 | canvas.height = 16; 490 | 491 | const g = canvas.getContext('2d'); 492 | g.drawImage(img, 0, 0); 493 | 494 | const data = g.getImageData(0, 0, 16, 16); 495 | for (let i = 0; i < data.data.length; i += 4) { 496 | if ( 497 | data.data[i + 0] === 255 && // r 498 | data.data[i + 1] === 255 && // g 499 | data.data[i + 2] === 255 // b 500 | ) { 501 | data.data[i + 0] = color.r; 502 | data.data[i + 1] = color.g; 503 | data.data[i + 2] = color.b; 504 | } 505 | } 506 | 507 | g.putImageData(data, 0, 0); 508 | img.src = canvas.toDataURL(); 509 | return img; 510 | }, 511 | 512 | /** 513 | * @param {HTMLImageElement} img 514 | * @returns {HTMLImageElement} 515 | */ 516 | _overlayCustomIcon: async function(img) { 517 | const customIcon = await Icon.get.custom(); 518 | if (customIcon == null) { return img; } 519 | 520 | const canvas = document.createElement('canvas'); 521 | canvas.width = 16; 522 | canvas.height = 16; 523 | 524 | const g = canvas.getContext('2d'); 525 | g.drawImage(img, 0, 0, 12, 12); 526 | g.drawImage(customIcon, 4, 4, 12, 12); 527 | 528 | img.src = canvas.toDataURL(); 529 | return img; 530 | }, 531 | 532 | /** 533 | * @param {HTMLImageElement} img 534 | * @returns {HTMLImageElement} 535 | */ 536 | _overlayNotification: function(img) { 537 | if (Errors.count() === 0) { return img; } 538 | 539 | const canvas = document.createElement('canvas'); 540 | canvas.width = 16; 541 | canvas.height = 16; 542 | 543 | const g = canvas.getContext('2d'); 544 | g.drawImage(img, 0, 0); 545 | g.arc(12, 4, 4, 0, 2 * Math.PI, false); 546 | g.fillStyle = 'red'; 547 | g.fill(); 548 | 549 | img.src = canvas.toDataURL(); 550 | return img; 551 | }, 552 | 553 | /** 554 | * @returns {void} 555 | */ 556 | uploadCustom: function() { 557 | const input = document.createElement('input'); 558 | input.type = 'file'; 559 | input.accept = 'image/*'; 560 | input.addEventListener('change', () => { 561 | if (!input.files || !input.files[0]) { return; } 562 | 563 | const img = document.createElement('img'); 564 | img.addEventListener('load', () => { 565 | URL.revokeObjectURL(img.src); 566 | 567 | const canvas = document.createElement('canvas'); 568 | canvas.width = 16; 569 | canvas.height = 16; 570 | 571 | canvas.getContext('2d').drawImage(img, 0, 0, 16, 16); 572 | 573 | Icon._customIconURL = canvas.toDataURL(); 574 | Icon.update(); 575 | 576 | BackgroundPage.send.setCustomIcon(Icon._customIconURL); 577 | }); 578 | img.src = URL.createObjectURL(input.files[0]); 579 | }); 580 | input.click(); 581 | }, 582 | }; 583 | 584 | function initialize() { 585 | Icon.init(); 586 | Group.set(Group.get(), false, true); 587 | BackgroundPage.init(); 588 | 589 | const nameInput = document.getElementById('name-input'); 590 | nameInput.addEventListener('input', Group.setInputWidth); 591 | nameInput.addEventListener('blur', Group.update); 592 | nameInput.addEventListener('keyup', function(e) { 593 | if (e.key === 'Enter') { Group.update(); } 594 | }); 595 | 596 | document.getElementById('highlight-btn').addEventListener('click', BackgroundPage.send.highlightTabs); 597 | document.getElementById('upload-custom-icon-btn').addEventListener('click', Icon.uploadCustom); 598 | document.getElementById('open-collapse-group-btn').addEventListener('click', function() { BackgroundPage.send.toggleOpenCollapse(); }); 599 | document.getElementById('should-keep-opened-tabs').addEventListener('change', function() { BackgroundPage.send.setShouldKeepOpenedTabs(document.getElementById('should-keep-opened-tabs').checked); }); 600 | document.getElementById('prompt-on-close').addEventListener('change', function() { BackgroundPage.send.setPromptOnClose(document.getElementById('prompt-on-close').checked); }); 601 | document.getElementById('remember-last-active-tab').addEventListener('change', function() { BackgroundPage.send.rememberLastActiveTab(document.getElementById('remember-last-active-tab').checked); }); 602 | document.getElementById('automatically-open-collapse').addEventListener('change', function() { 603 | const checked = document.getElementById('automatically-open-collapse').checked; 604 | BackgroundPage.send.automaticallyOpenCollapse(checked); 605 | document.getElementById('open-collapse-group-btn').classList.toggle('hidden', checked); 606 | }); 607 | } 608 | 609 | window.addEventListener('DOMContentLoaded', async () => { 610 | if (Group.get() == null) { /** @see Tabs._on.created */ 611 | document.getElementById('active-content').classList.add('hidden'); 612 | } else { 613 | document.getElementById('inactive-content').classList.add('hidden'); 614 | initialize(); 615 | } 616 | }); 617 | 618 | window.addEventListener('beforeunload', (e) => { 619 | if (document.getElementById('prompt-on-close')?.checked) { 620 | e.preventDefault(); 621 | } 622 | }); -------------------------------------------------------------------------------- /src/tabs.js: -------------------------------------------------------------------------------- 1 | /** BrowserTab 2 | * @typedef {object} BrowserTab https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/tabs/Tab 3 | * @property {number} id 4 | * @property {number} openerTabId 5 | * @property {number} windowId 6 | * @property {number} index 7 | * @property {'loading'|'complete'} status 8 | * @property {string} favIconUrl 9 | * @property {string} title 10 | * @property {string} url 11 | */ 12 | 13 | const Tabs = { 14 | /** 15 | * @returns {Promise} 16 | */ 17 | init: async function() { 18 | if (!browser.tabs.onCreated.hasListener(Tabs._on.created)) { 19 | browser.tabs.onCreated.addListener(Tabs._on.created); 20 | } 21 | 22 | if (!browser.tabs.onActivated.hasListener(Tabs._on.activated)) { 23 | browser.tabs.onActivated.addListener(Tabs._on.activated); 24 | } 25 | 26 | if (!browser.tabs.onAttached.hasListener(Tabs._on.attached)) { 27 | browser.tabs.onAttached.addListener(Tabs._on.attached); 28 | } 29 | 30 | if (!browser.tabs.onUpdated.hasListener(Tabs._on.updated)) { 31 | browser.tabs.onUpdated.addListener(Tabs._on.updated); 32 | } 33 | 34 | if (!browser.tabs.onMoved.hasListener(Tabs._on.moved)) { 35 | browser.tabs.onMoved.addListener(Tabs._on.moved); 36 | } 37 | 38 | if (!browser.tabs.onRemoved.hasListener(Tabs._on.removed)) { 39 | browser.tabs.onRemoved.addListener(Tabs._on.removed); 40 | } 41 | }, 42 | 43 | /** 44 | * @param {number} tabID 45 | * @returns {Promise} 46 | */ 47 | get: function(tabID) { 48 | return new Promise((resolve) => { 49 | browser.tabs.get(tabID).then((value) => resolve(value ?? null), () => resolve(null)); 50 | }); 51 | }, 52 | 53 | /** 54 | * @param {number} tabID 55 | * @returns {Promise} 56 | */ 57 | show: function(tabID) { 58 | return new Promise((resolve) => { 59 | browser.tabs.show(tabID).then(() => resolve(), () => resolve()); 60 | }); 61 | }, 62 | 63 | /** 64 | * @param {number} tabID 65 | * @returns {Promise} 66 | */ 67 | hide: function(tabID) { 68 | return new Promise((resolve) => { 69 | browser.tabs.hide(tabID).then(() => resolve(), () => resolve()); 70 | }); 71 | }, 72 | 73 | /** 74 | * @param {number} tabID 75 | * @returns {Promise} 76 | */ 77 | setActive: async function(tabID) { 78 | await browser.tabs.update(tabID, {active: true}); 79 | }, 80 | 81 | /** 82 | * @param {string} group 83 | * @param {number|null} [windowID=null] The ID of the window that owns the group 84 | * @param {boolean} [includeGroupTab=false] 85 | * @returns {Promise} 86 | */ 87 | getTabsInGroup: async function(group, windowID = null, includeGroupTab = false) { 88 | const retval = []; 89 | 90 | windowID = windowID ?? await Windows.getIDForGroup(group); 91 | const tabs = await Windows.getAllTabsIn(windowID); 92 | for (const tab of tabs) { 93 | const tabGroup = await Tabs.value.get.group(tab.id); 94 | const isGroupTab = await Tabs.value.get.isGroupTab(tab.id); 95 | if (tabGroup === group && (includeGroupTab || !isGroupTab)) { 96 | retval.push(tab); 97 | } 98 | } 99 | 100 | return retval; 101 | }, 102 | 103 | /** 104 | * @param {string} group 105 | * @returns {Promise} 106 | */ 107 | highlightTabsInGroup: async function(group) { 108 | const tabs = await Tabs.getTabsInGroup(group); 109 | for (const tab of tabs) { 110 | await browser.tabs.update(tab.id, {highlighted: true, active: false}); 111 | } 112 | }, 113 | 114 | /** 115 | * @param {string} group 116 | * @param {number} windowID The ID of the window that owns the group 117 | * @param {boolean} includeGroupTab 118 | * @param {number[]} except The IDs of any tabs that shouldn't be part of the calculation 119 | * @returns {Promise<{min:number, max: number}|null>} 120 | */ 121 | getGroupIndexSpan: async function(group, windowID, includeGroupTab = false, except = []) { 122 | let tabs = await Tabs.getTabsInGroup(group, windowID, includeGroupTab); 123 | tabs = tabs.filter((tab) => !except.includes(tab.id)).sort((a, b) => a.index - b.index); 124 | if (tabs.length === 0) { return null; } 125 | return {min: tabs[0].index, max: tabs[tabs.length - 1].index}; 126 | }, 127 | 128 | move: { 129 | /** 130 | * @param {number} tabID 131 | * @param {number} index 132 | * @param {number|null} [windowID] 133 | * @returns {Promise} 134 | */ 135 | toIndex: async function(tabID, index, windowID = null) { 136 | if (windowID == null) { 137 | await browser.tabs.move(tabID, {index}); 138 | } else { 139 | await browser.tabs.move(tabID, {index, windowId: windowID}); 140 | } 141 | }, 142 | 143 | /** 144 | * @param {number} tabID 145 | * @returns {Promise} 146 | */ 147 | intoGroup: async function(tabID) { 148 | const tab = await Tabs.get(tabID); 149 | const group = await Tabs.value.get.group(tab.id); 150 | if (group == null) { return; } 151 | const groupWindowID = await Windows.getIDForGroup(group); 152 | const span = await Tabs.getGroupIndexSpan(group, groupWindowID, true, [tab.id]); 153 | if (span == null) { return; } 154 | 155 | if (groupWindowID !== tab.windowId) { 156 | await Tabs.move.toIndex(tab.id, span.max + 1, groupWindowID); 157 | } else if (tab.index <= span.min) { 158 | await Tabs.move.toIndex(tab.id, span.min); 159 | } else if (tab.index > span.max + 1) { 160 | await Tabs.move.toIndex(tab.id, span.max + 1); 161 | } 162 | }, 163 | 164 | /** 165 | * @param {number} tabID 166 | * @param {number|null} [windowID=null] 167 | * @param {number|null} [index=null] 168 | * @returns {Promise} 169 | */ 170 | outOfOtherGroups: async function(tabID, windowID = null, index = null) { 171 | if (windowID == null || index == null) { 172 | const tab = await Tabs.get(tabID); 173 | if (tab == null) { return null; } 174 | 175 | windowID = tab.windowId; 176 | index = tab.index; 177 | } 178 | 179 | const except = await Tabs.value.get.group(tabID); 180 | const groups = await Windows.getAllGroupsIn(windowID); 181 | 182 | for (const group of groups) { 183 | if (group === except) { continue; } 184 | 185 | const span = await Tabs.getGroupIndexSpan(group, windowID, true); 186 | if (span == null) { continue; } 187 | 188 | if (index >= span.min & index <= span.max) { 189 | await Tabs.move.toIndex(tabID, span.max); 190 | return span.max; // After this we don't need to check any more, if we assume that all tabs BUT the current tab are correct 191 | } 192 | } 193 | 194 | return index; 195 | }, 196 | }, 197 | 198 | value: { 199 | keys: { 200 | group: 'group', 201 | isGroupTab: 'group-tab', 202 | shouldKeepOpenedTabs: 'should-inherit-group', 203 | iconColor: 'icon-color', 204 | customIconURL: 'custom-icon-url', 205 | promptOnClose: 'prompt-on-close', 206 | rememberLastActiveTab: 'remember-last-active-tab', 207 | automaticallyOpenCollapse: 'automatically-open-collapse', 208 | }, 209 | 210 | get: { 211 | /** 212 | * @param {number} tabID 213 | * @param {*} defaultValue 214 | * @returns {Promise} 215 | */ 216 | group: async function(tabID, defaultValue = null) { 217 | return Tabs.value.get._value(tabID, Tabs.value.keys.group, defaultValue); 218 | }, 219 | 220 | /** 221 | * @param {number} tabID 222 | * @param {*} defaultValue 223 | * @returns {Promise} 224 | */ 225 | isGroupTab: async function(tabID, defaultValue = false) { 226 | return Tabs.value.get._value(tabID, Tabs.value.keys.isGroupTab, defaultValue); 227 | }, 228 | 229 | /** 230 | * @param {number} tabID 231 | * @param {*} defaultValue 232 | * @returns {Promise} 233 | */ 234 | shouldKeepOpenedTabs: async function(tabID, defaultValue = false) { 235 | return Tabs.value.get._value(tabID, Tabs.value.keys.shouldKeepOpenedTabs, defaultValue); 236 | }, 237 | 238 | /** 239 | * @param {number} tabID 240 | * @param {*} defaultValue 241 | * @returns {Promise} 242 | */ 243 | iconColor: async function(tabID, defaultValue = null) { 244 | return Tabs.value.get._value(tabID, Tabs.value.keys.iconColor, defaultValue); 245 | }, 246 | 247 | /** 248 | * @param {number} tabID 249 | * @param {*} defaultValue 250 | * @returns {Promise} 251 | */ 252 | customIconURL: async function(tabID, defaultValue = null) { 253 | return Tabs.value.get._value(tabID, Tabs.value.keys.customIconURL, defaultValue); 254 | }, 255 | 256 | /** 257 | * @param {number} tabID 258 | * @param {*} defaultValue 259 | * @returns {Promise} 260 | */ 261 | promptOnClose: async function(tabID, defaultValue = false) { 262 | return Tabs.value.get._value(tabID, Tabs.value.keys.promptOnClose, defaultValue); 263 | }, 264 | 265 | /** 266 | * @param {number} tabID 267 | * @param {*} defaultValue 268 | * @returns {Promise} 269 | */ 270 | rememberLastActiveTab: async function(tabID, defaultValue = false) { 271 | return Tabs.value.get._value(tabID, Tabs.value.keys.rememberLastActiveTab, defaultValue); 272 | }, 273 | 274 | /** 275 | * @param {number} tabID 276 | * @param {*} defaultValue 277 | * @returns {Promise} 278 | */ 279 | automaticallyOpenCollapse: async function(tabID, defaultValue = true) { 280 | return Tabs.value.get._value(tabID, Tabs.value.keys.automaticallyOpenCollapse, defaultValue); 281 | }, 282 | 283 | /** 284 | * @param {number} tabID 285 | * @param {string} key 286 | * @param {*} [defaultValue=null] 287 | * @returns {Promise<*|null>} 288 | */ 289 | _value: function(tabID, key, defaultValue = null) { 290 | return new Promise((resolve) => { 291 | browser.sessions.getTabValue(tabID, key).then((value) => resolve(value ?? defaultValue), () => resolve(defaultValue)); 292 | }); 293 | }, 294 | }, 295 | 296 | getViaGroup: { 297 | /** 298 | * @param {string} group 299 | * @throws {Error} Always 300 | */ 301 | group: async function(group) { 302 | throw new Error("That doesn't make any sense"); 303 | }, 304 | 305 | /** 306 | * @param {string} group 307 | * @throws {Error} Always 308 | */ 309 | isGroupTab: async function(group) { 310 | throw new Error("That doesn't make any sense either"); 311 | }, 312 | 313 | /** 314 | * @param {string} group 315 | * @returns {Promise} 316 | */ 317 | shouldKeepOpenedTabs: async function(group) { 318 | const groupTab = await Groups.groupTab.get(group); 319 | return await Tabs.value.get.shouldKeepOpenedTabs(groupTab.id); 320 | }, 321 | 322 | /** 323 | * @param {string} group 324 | * @returns {Promise} 325 | */ 326 | iconColor: async function(group) { 327 | const groupTab = await Groups.groupTab.get(group); 328 | return await Tabs.value.get.iconColor(groupTab.id); 329 | }, 330 | 331 | /** 332 | * @param {string} group 333 | * @returns {Promise} 334 | */ 335 | customIconURL: async function(group) { 336 | const groupTab = await Groups.groupTab.get(group); 337 | return await Tabs.value.get.customIconURL(groupTab.id); 338 | }, 339 | 340 | /** 341 | * @param {string} group 342 | * @returns {Promise} 343 | */ 344 | promptOnClose: async function(group) { 345 | const groupTab = await Groups.groupTab.get(group); 346 | return await Tabs.value.get.promptOnClose(groupTab.id); 347 | }, 348 | 349 | /** 350 | * @param {string} group 351 | * @returns {Promise} 352 | */ 353 | rememberLastActiveTab: async function(group) { 354 | const groupTab = await Groups.groupTab.get(group); 355 | return await Tabs.value.get.rememberLastActiveTab(groupTab.id); 356 | }, 357 | 358 | /** 359 | * @param {string} group 360 | * @returns {Promise} 361 | */ 362 | automaticallyOpenCollapse: async function(group) { 363 | const groupTab = await Groups.groupTab.get(group); 364 | return await Tabs.value.get.automaticallyOpenCollapse(groupTab.id); 365 | }, 366 | }, 367 | 368 | set: { 369 | /** 370 | * @param {number} tabID 371 | * @param {string} group 372 | * @returns {Promise} 373 | */ 374 | group: async function(tabID, group) { 375 | Groups.groupedTabCache[tabID] = group; 376 | await Tabs.value.set._value(tabID, Tabs.value.keys.group, group); 377 | }, 378 | 379 | /** 380 | * @param {number} tabID 381 | * @param {true} value 382 | * @returns {Promise} 383 | */ 384 | isGroupTab: async function(tabID, value) { 385 | await Tabs.value.set._value(tabID, Tabs.value.keys.isGroupTab, value); 386 | }, 387 | 388 | /** 389 | * @param {number} tabID 390 | * @param {boolean} value 391 | * @returns {Promise} 392 | */ 393 | shouldKeepOpenedTabs: async function(tabID, value) { 394 | await Tabs.value.set._value(tabID, Tabs.value.keys.shouldKeepOpenedTabs, value); 395 | }, 396 | 397 | /** 398 | * @param {number} tabID 399 | * @param {string} value 400 | * @returns {Promise} 401 | */ 402 | iconColor: async function(tabID, value) { 403 | await Tabs.value.set._value(tabID, Tabs.value.keys.iconColor, value); 404 | }, 405 | 406 | /** 407 | * @param {number} tabID 408 | * @param {string} value 409 | * @returns {Promise} 410 | */ 411 | customIconURL: async function(tabID, value) { 412 | await Tabs.value.set._value(tabID, Tabs.value.keys.customIconURL, value); 413 | }, 414 | 415 | /** 416 | * @param {number} tabID 417 | * @param {boolean} value 418 | * @returns {Promise} 419 | */ 420 | promptOnClose: async function(tabID, value) { 421 | await Tabs.value.set._value(tabID, Tabs.value.keys.promptOnClose, value); 422 | }, 423 | 424 | /** 425 | * @param {number} tabID 426 | * @param {boolean} value 427 | * @returns {Promise} 428 | */ 429 | rememberLastActiveTab: async function(tabID, value) { 430 | await Tabs.value.set._value(tabID, Tabs.value.keys.rememberLastActiveTab, value); 431 | }, 432 | 433 | /** 434 | * @param {number} tabID 435 | * @param {boolean} value 436 | * @returns {Promise} 437 | */ 438 | automaticallyOpenCollapse: async function(tabID, value) { 439 | await Tabs.value.set._value(tabID, Tabs.value.keys.automaticallyOpenCollapse, value); 440 | }, 441 | 442 | /** 443 | * @param {number} tabID 444 | * @param {string} key 445 | * @param {*} value 446 | * @returns {Promise} 447 | */ 448 | _value: async function(tabID, key, value) { 449 | await browser.sessions.setTabValue(tabID, key, value); 450 | }, 451 | }, 452 | 453 | initialize: { 454 | /** 455 | * Sets the value, if it wasn't set before 456 | * 457 | * @param {number} tabID 458 | * @param {string} group 459 | * @returns {Promise} 460 | */ 461 | group: async function(tabID, group) { 462 | const currentValue = await Tabs.value.get.group(tabID, null); 463 | if (currentValue == null) { 464 | await Tabs.value.set.group(tabID, group); 465 | } 466 | }, 467 | 468 | /** 469 | * Sets the value, if it wasn't set before 470 | * 471 | * @param {number} tabID 472 | * @param {true} value 473 | * @returns {Promise} 474 | */ 475 | isGroupTab: async function(tabID, value) { 476 | const currentValue = await Tabs.value.get.isGroupTab(tabID, null); 477 | if (currentValue == null) { 478 | await Tabs.value.set.isGroupTab(tabID, value); 479 | } 480 | }, 481 | 482 | /** 483 | * Sets the value, if it wasn't set before 484 | * 485 | * @param {number} tabID 486 | * @param {true} value 487 | * @returns {Promise} 488 | */ 489 | shouldKeepOpenedTabs: async function(tabID, value) { 490 | const currentValue = await Tabs.value.get.shouldKeepOpenedTabs(tabID, null); 491 | if (currentValue == null) { 492 | await Tabs.value.set.shouldKeepOpenedTabs(tabID, value); 493 | } 494 | }, 495 | 496 | /** 497 | * Sets the value, if it wasn't set before 498 | * 499 | * @param {number} tabID 500 | * @param {string} value 501 | * @returns {Promise} 502 | */ 503 | iconColor: async function(tabID, value) { 504 | const currentValue = await Tabs.value.get.iconColor(tabID, null); 505 | if (currentValue == null) { 506 | await Tabs.value.set.iconColor(tabID, value); 507 | } 508 | }, 509 | 510 | /** 511 | * Sets the value, if it wasn't set before 512 | * 513 | * @param {number} tabID 514 | * @param {string} value 515 | * @returns {Promise} 516 | */ 517 | customIconURL: async function(tabID, value) { 518 | const currentValue = await Tabs.value.get.customIconURL(tabID, null); 519 | if (currentValue == null) { 520 | await Tabs.value.set.customIconURL(tabID, value); 521 | } 522 | }, 523 | 524 | /** 525 | * Sets the value, if it wasn't set before 526 | * 527 | * @param {number} tabID 528 | * @param {true} value 529 | * @returns {Promise} 530 | */ 531 | promptOnClose: async function(tabID, value) { 532 | const currentValue = await Tabs.value.get.promptOnClose(tabID, null); 533 | if (currentValue == null) { 534 | await Tabs.value.set.promptOnClose(tabID, value); 535 | } 536 | }, 537 | 538 | /** 539 | * Sets the value, if it wasn't set before 540 | * 541 | * @param {number} tabID 542 | * @param {true} value 543 | * @returns {Promise} 544 | */ 545 | rememberLastActiveTab: async function(tabID, value) { 546 | const currentValue = await Tabs.value.get.rememberLastActiveTab(tabID, null); 547 | if (currentValue == null) { 548 | await Tabs.value.set.rememberLastActiveTab(tabID, value); 549 | } 550 | }, 551 | 552 | /** 553 | * Sets the value, if it wasn't set before 554 | * 555 | * @param {number} tabID 556 | * @param {true} value 557 | * @returns {Promise} 558 | */ 559 | automaticallyOpenCollapse: async function(tabID, value) { 560 | const currentValue = await Tabs.value.get.automaticallyOpenCollapse(tabID, null); 561 | if (currentValue == null) { 562 | await Tabs.value.set.automaticallyOpenCollapse(tabID, value); 563 | } 564 | }, 565 | }, 566 | 567 | remove: { 568 | /** 569 | * @param {number} tabID 570 | * @returns {Promise} 571 | */ 572 | all: async function(tabID) { 573 | await Tabs.value.remove.group(tabID); 574 | await Tabs.value.remove.isGroupTab(tabID); 575 | await Tabs.value.remove.shouldKeepOpenedTabs(tabID); 576 | await Tabs.value.remove.iconColor(tabID); 577 | await Tabs.value.remove.customIconURL(tabID); 578 | await Tabs.value.remove.promptOnClose(tabID); 579 | await Tabs.value.remove.rememberLastActiveTab(tabID); 580 | await Tabs.value.remove.automaticallyOpenCollapse(tabID); 581 | }, 582 | 583 | /** 584 | * @param {number} tabID 585 | * @returns {Promise} 586 | */ 587 | group: async function(tabID) { 588 | delete Groups.groupedTabCache[tabID]; 589 | await Tabs.value.remove._value(tabID, Tabs.value.keys.group); 590 | }, 591 | 592 | /** 593 | * @param {number} tabID 594 | * @returns {Promise} 595 | */ 596 | isGroupTab: async function(tabID) { 597 | await Tabs.value.remove._value(tabID, Tabs.value.keys.isGroupTab); 598 | }, 599 | 600 | /** 601 | * @param {number} tabID 602 | * @returns {Promise} 603 | */ 604 | shouldKeepOpenedTabs: async function(tabID) { 605 | await Tabs.value.remove._value(tabID, Tabs.value.keys.shouldKeepOpenedTabs); 606 | }, 607 | 608 | /** 609 | * @param {number} tabID 610 | * @returns {Promise} 611 | */ 612 | iconColor: async function(tabID) { 613 | await Tabs.value.remove._value(tabID, Tabs.value.keys.iconColor); 614 | }, 615 | 616 | /** 617 | * @param {number} tabID 618 | * @returns {Promise} 619 | */ 620 | customIconURL: async function(tabID) { 621 | await Tabs.value.remove._value(tabID, Tabs.value.keys.customIconURL); 622 | }, 623 | 624 | /** 625 | * @param {number} tabID 626 | * @returns {Promise} 627 | */ 628 | promptOnClose: async function(tabID) { 629 | await Tabs.value.remove._value(tabID, Tabs.value.keys.promptOnClose); 630 | }, 631 | 632 | /** 633 | * @param {number} tabID 634 | * @returns {Promise} 635 | */ 636 | rememberLastActiveTab: async function(tabID) { 637 | await Tabs.value.remove._value(tabID, Tabs.value.keys.rememberLastActiveTab); 638 | }, 639 | 640 | /** 641 | * @param {number} tabID 642 | * @returns {Promise} 643 | */ 644 | automaticallyOpenCollapse: async function(tabID) { 645 | await Tabs.value.remove._value(tabID, Tabs.value.keys.automaticallyOpenCollapse); 646 | }, 647 | 648 | /** 649 | * @param {number} tabID 650 | * @param {string} key 651 | * @returns {Promise} 652 | */ 653 | _value: function(tabID, key) { 654 | return new Promise((resolve) => { 655 | browser.sessions.removeTabValue(tabID, key).then(() => resolve(), () => resolve()); 656 | }); 657 | }, 658 | }, 659 | }, 660 | 661 | _on: { 662 | /** 663 | * This is needed due to the fact that me calling move for some reason triggers the onMoved callback for me. 664 | * In order to not reach stack depth immediately, don't try to process tabs that are being processed. 665 | * 666 | * @type {Record} Maps tab ID to nothing worth noting 667 | */ 668 | movedCache: {}, 669 | 670 | /** 671 | * Since Firefox is stupid and doesn't respect finishing callback calls if there are awaits in them, 672 | * we need to do that part manually ourselves. 673 | * The fact that Firefox doesn't do this would be fine, if it weren't for the fact that EVERYTHING in plugin dev is asynchronous. 674 | * This of course means that there is basically NOTHING we can do in a callback without encountering this issue. 675 | * 676 | * If we don't do this, we run into issues when we reopen a previously grouped tab: the tab gets removed from the group, 677 | * but is also marked as the last active tab, which is not great (for obvious reasons). 678 | * The issue could also be mitigated if we were allowed to look at and set session values for tabs in the onRemoved event, but for some (surely pure genious) reason we can't. 679 | * @see Tabs._on.created 680 | * @see Tabs._on.activated 681 | * 682 | * @type {Record>} Maps the tab ID to a promise that we can await to be able to resume when the function should ACTUALLY have been called 683 | */ 684 | creatingCache: {}, 685 | 686 | /** 687 | * @param {BrowserTab} tab 688 | * @returns {Promise} 689 | */ 690 | created: async function(tab) { 691 | let resolve = null; 692 | Tabs._on.creatingCache[tab.id] = new Promise((_resolve) => resolve = _resolve); 693 | 694 | const group = await Tabs.value.get.group(tab.id); 695 | const isGroupTab = await Tabs.value.get.isGroupTab(tab.id); 696 | 697 | let windowID = tab.windowId; 698 | 699 | if (isGroupTab) { // Reopened a closed group tab 700 | const otherGroupTab = await Groups.groupTab.get(group, null, tab.id); 701 | const tabsInGroup = await Tabs.getTabsInGroup(group, windowID, false); 702 | if (otherGroupTab != null || tabsInGroup.length === 0) { // There is a new group with the same name as the restored tab - deinit this one 703 | await Groups.groupTab.deinit(tab.id); 704 | await browser.tabs.update(tab.id, {url: Groups.groupTab.getURL(null), loadReplace: true}); // Remove the ?group parameter, which should signal to the tab that it isn't active anymore 705 | } else { // We can safely restore this tab group 706 | const promptOnCloseWasSet = await Tabs.value.get.promptOnClose(tab.id); 707 | await Tabs.value.set.promptOnClose(tab.id, false); 708 | 709 | await Groups.groupTab.init(tab.id, group, windowID); 710 | await Groups.collapse.allExceptCurrent(windowID); 711 | await Menus.addGroup(group); 712 | 713 | if (promptOnCloseWasSet) { 714 | await Communication.send.errors(group, ['Due to browser restrictions, this reopened tab had to have its "Prompt me if I try to close this tab group" status unset']); 715 | } 716 | } 717 | } else if (group) { // Reopened a tab that used to be grouped 718 | const groupTab = await Groups.groupTab.get(group); 719 | if (groupTab != null && groupTab.windowId === windowID) { // The group still exists in the proper window 720 | await Groups.collapse.allExceptCurrent(windowID); 721 | await Groups.groupTab.update(group, windowID, groupTab.id); 722 | } else { // Group is either gone, or in another window 723 | await Tabs.value.remove.all(tab.id); 724 | } 725 | } else if (tab.openerTabId != null) { // Opened a new tab from another tab 726 | const openerGroup = await Tabs.value.get.group(tab.openerTabId); 727 | if (openerGroup != null) { 728 | const groupTab = await Groups.groupTab.get(openerGroup); 729 | const shouldKeepOpenedTabs = await Tabs.value.get.shouldKeepOpenedTabs(groupTab.id); 730 | if (shouldKeepOpenedTabs) { 731 | await Groups.addTabTo(openerGroup, tab); 732 | windowID = groupTab.windowId; 733 | } 734 | } 735 | } 736 | 737 | await Tabs.move.outOfOtherGroups(tab.id, tab.windowId, tab.index); 738 | await Groups.collapse.allExceptCurrent(windowID); 739 | 740 | delete Tabs._on.creatingCache[tab.id]; 741 | resolve(); 742 | }, 743 | 744 | /** 745 | * @param {{previousTabId: number, tabId: number, windowId: number}} info https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/tabs/onActivated#activeinfo_2 746 | * @returns {Promise} 747 | */ 748 | activated: async function(info) { 749 | if (info.tabId in Tabs._on.creatingCache) { 750 | await Tabs._on.creatingCache[info.tabId]; 751 | } 752 | 753 | const newGroup = await Tabs.value.get.group(info.tabId); 754 | const oldGroup = await Tabs.value.get.group(info.previousTabId); 755 | await Groups.collapse.allExcept(newGroup, info.windowId); 756 | 757 | if (newGroup != null) { 758 | const automaticallyOpenCollapse = await Tabs.value.getViaGroup.automaticallyOpenCollapse(newGroup); 759 | 760 | if (newGroup === oldGroup) { // Swapped tabs within the same group - update the last active tab 761 | Groups.lastActiveTab.set(newGroup, info.tabId); 762 | } else if (automaticallyOpenCollapse) { 763 | const lastActiveTab = await Groups.lastActiveTab.get(newGroup); 764 | if (lastActiveTab != null) { 765 | await Tabs.setActive(lastActiveTab); 766 | } 767 | } 768 | } 769 | }, 770 | 771 | /** 772 | * @param {number} tabID 773 | * @param {{newWindowId: number, newPosition: number}} info https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/onAttached#attachinfo_2 774 | * @returns {Promisevdoi>} 775 | */ 776 | attached: async function(tabID, info) { 777 | const group = Groups.groupedTabCache[tabID]; 778 | const wasGroupTabOf = Groups.groupTab.cache[tabID]; 779 | 780 | if (wasGroupTabOf != null) { // A group tab was moved to a new window, recreate the group 781 | const oldWindowID = await Windows.getIDForGroup(group); 782 | await Groups.groupTab.init(tabID, group, oldWindowID); 783 | 784 | const tabs = await Tabs.getTabsInGroup(group, oldWindowID); 785 | for (const tab of tabs) { 786 | await Tabs.move.intoGroup(tab.id); 787 | } 788 | 789 | await Group.collapse.allExceptCurrent(info.newWindowId); 790 | await Group.collapse.allExceptCurrent(oldWindowID); 791 | } else if (group) { // A grouped tab was moved to a new window 792 | const oldWindowID = await Windows.getIDForGroup(group); 793 | if (info.newWindowId !== oldWindowID) { // Tell the remainder of the group of the deserter 794 | await Groups.removeTabFrom(group, tabID); 795 | } else { // Probably part of a group tab move - reattach the tab to the group 796 | await Groups.addTabTo(group, await Tabs.get(tabID)); 797 | } 798 | } 799 | }, 800 | 801 | /** 802 | * @param {number} tabID 803 | * @param {Partial} changeInfo 804 | * @param {BrowserTab} tab 805 | * @returns {Promise} 806 | */ 807 | updated: async function(tabID, changeInfo, tab) { 808 | if (changeInfo.status !== 'complete') { return; } // Slight performance boost maybe? 809 | 810 | const group = await Tabs.value.get.group(tabID); 811 | const isGroupTab = await Tabs.value.get.isGroupTab(tabID); 812 | 813 | if (isGroupTab) { // Some naughty user refreshed/navigated away from their group tab. Bad user, bad! 814 | const url = new URL(Groups.groupTab.getURL(group)); 815 | const tabURL = new URL(tab.url); 816 | if (!Background.urlEqual(url, tabURL)) { 817 | await browser.tabs.update(tabID, {url: url.href, loadReplace: true}); 818 | return; 819 | } 820 | 821 | await Groups.groupTab.init(tabID, group, tab.windowId); 822 | } else if (group != null) { 823 | Communication.send.updateTab(group, tab); 824 | } 825 | }, 826 | 827 | /** 828 | * This only handles moves within the same window. For swapping between: 829 | * @see Tabs._on.attached. 830 | * 831 | * @param {number} tabID 832 | * @param {{windowId: number, fromIndex: number, toIndex: number}} info https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/tabs/onMoved#moveinfo_2 833 | * @returns {Promise} 834 | */ 835 | moved: async function(tabID, info) { 836 | if (tabID in Tabs._on.movedCache) { return; } 837 | Tabs._on.movedCache[tabID] = true; 838 | 839 | const group = await Tabs.value.get.group(tabID); 840 | const isGroupTab = await Tabs.value.get.isGroupTab(tabID); 841 | 842 | if (isGroupTab) { // A group tab was moved 843 | // Make sure it's not in the middle of some other group 844 | const index = await Tabs.move.outOfOtherGroups(tabID, info.windowId, info.toIndex); 845 | 846 | // Move all tabs belonging to it the same distance in the same direction 847 | const offset = index - info.fromIndex; 848 | const tabs = await Tabs.getTabsInGroup(group, info.windowId); 849 | 850 | for (const t of tabs) { 851 | if (t.id in Tabs._on.movedCache) { continue; } 852 | Tabs._on.movedCache[t.id] = true; 853 | 854 | const tab = await Tabs.get(t.id); // Refresh tab index 855 | await Tabs.move.toIndex(tab.id, tab.index + offset); 856 | 857 | delete Tabs._on.movedCache[t.id]; 858 | } 859 | } else if (group != null) { // A tab that's part of a group (but isn't the group tab!) was moved. Make sure it stays within bounds 860 | await Tabs.move.intoGroup(tabID); 861 | Communication.send.sortTabs(group, await Tabs.getTabsInGroup(group, info.windowId)); 862 | } else { // A non-group tab was moved. Make sure it doesn't end up in the middle of a group 863 | await Tabs.move.outOfOtherGroups(tabID, info.windowId, info.toIndex); 864 | } 865 | 866 | delete Tabs._on.movedCache[tabID]; 867 | }, 868 | 869 | /** 870 | * @param {number} tabID 871 | * @param {{windowId: number, isWindowClosing: boolean}} info https://developer.mozilla.org/docs/Mozilla/Add-ons/WebExtensions/API/tabs/onRemoved#removeinfo_2 872 | * @returns {Promise} 873 | */ 874 | removed: async function(tabID, info) { 875 | if (tabID in Groups.groupTab.cache) { // Equivalent to "was a group tab" 876 | await Groups.remove(Groups.groupTab.cache[tabID]); 877 | } else if (tabID in Groups.groupedTabCache) { // Equivalent to "was part of a group" 878 | await Groups.removeTabFrom(Groups.groupedTabCache[tabID], tabID); 879 | } 880 | 881 | delete Groups.groupTab.cache[tabID]; 882 | delete Groups.groupedTabCache[tabID]; 883 | 884 | const group = await Windows.getCurrentGroupIn(info.windowId); 885 | await Groups.collapse.allExcept(group, info.windowId); 886 | if (group != null) { 887 | const lastActive = await Groups.lastActiveTab.get(group); 888 | if (lastActive != null) { 889 | await Tabs.setActive(lastActive); 890 | } 891 | } 892 | }, 893 | }, 894 | }; --------------------------------------------------------------------------------