├── .gitignore ├── .web-extension-id ├── Dockerfile ├── LICENSE ├── README.md ├── conex-actions-common.js ├── conex-background.js ├── conex-browser-action.html ├── conex-browser-action.js ├── conex-components.js ├── conex-helper.js ├── conex-options-ui.css ├── conex-options-ui.html ├── conex-options-ui.js ├── conex.css ├── container-selector.html ├── example-sessions.tar.bz2 ├── favicon.ico ├── icons ├── blue_dot.svg ├── clock.afdesign ├── clock.svg ├── green_dot.svg ├── icon.afdesign ├── icon_19.png ├── icon_38.png ├── icon_48.png ├── icon_64.png ├── icon_bw_48.png ├── icon_error_19.png ├── icon_error_38.png ├── icon_error_48.png ├── loudspeaker.svg ├── orange_dot.svg ├── pink_dot.svg ├── private-browsing.svg ├── purple_dot.svg ├── red_dot.svg ├── turquoise_dot.svg └── yellow_dot.svg ├── manifest.json ├── release ├── tabgroups-backup.json └── versions.json /.gitignore: -------------------------------------------------------------------------------- 1 | /example-sessions 2 | /node_modules 3 | /tags 4 | /web-ext-artifacts 5 | -------------------------------------------------------------------------------- /.web-extension-id: -------------------------------------------------------------------------------- 1 | # This file was created by https://github.com/mozilla/web-ext 2 | # Your auto-generated extension ID for addons.mozilla.org is: 3 | {cad4f60e-e46e-4d66-8c11-e09194d4df7d} -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node 2 | RUN npm install --global web-ext 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | [![Waffle.io - Columns and their card count](https://badge.waffle.io/kesselborn/conex.svg?columns=all)](https://waffle.io/kesselborn/conex) 3 | 4 | 5 | 6 | - [Conex](#conex) 7 | - [Differences to tabgroups](#differences-to-tabgroups) 8 | - [Compatibility](#compatibility) 9 | - [Installation](#installation) 10 | - [Getting back my last tab group session](#getting-back-my-last-tab-group-session) 11 | - [Necessary addon permissions](#necessary-addon-permissions) 12 | 13 | 14 | 15 | # Conex 16 | This addon tries to replace some functionality from the discontinued *TabGroups* with some differences: 17 | 18 | ## Differences to tabgroups 19 | 20 | - it lacks the big "Manage my TabGroups" overview window 21 | - conex uses containers for grouping tabs 22 | - the quick search contains thumbnail of the results 23 | - the quick search optionally includes bookmarks and history 24 | 25 | ## Installation 26 | 27 | Just install from the [official mozilla addons page](https://addons.mozilla.org/en-us/firefox/addon/conex) 28 | 29 | ## Getting back my last tab group session 30 | 31 | So: Firefox Quantum rolled in, tab groups doesn't work anymore and you would like to get back 32 | your last tab groups session? Here is a way you can get a tab groups backup which again you 33 | can import to Conex (note: only tested it briefly, but it's worth a try): 34 | 35 | - download [the latest Firefox version](https://ftp.mozilla.org/pub/firefox/releases/) that you successfully used TagGroups with ... the latest version that fully supported TagGroups (including the settings page) should be [v56](https://ftp.mozilla.org/pub/firefox/releases/56.0.2/) (using [Firefox ESR can cause problems](https://github.com/kesselborn/conex/issues/151)) 36 | - close Firefox Quantum 37 | - open the old Firefox 38 | - if you see tab groups in your addon section but it is disabled, please do the following: 39 | - open the url `about:config` and acknowledge the warning. Change the following settings: 40 | 41 | xpinstall.signatures.required: false 42 | 43 | - if you are lucky, your old session is restored and you can just open tab-groups and create a manual backup which you can use as an import for Conex 44 | - if your session is not restored, all hope is not lost: 45 | - go to tab-groups preferences and open the 'Backup / Restore' section 46 | - click the 'Load Groups and Tabs From ...' button and select 'Previous Session' 47 | - once imported, create the manual backup now 48 | 49 | ## Necessary addon permissions 50 | 51 | | permission | reason | 52 | |----------------------|----------------------------------------------------------| 53 | | | for taking screenshots for the thumbnails during search | 54 | | bookmarks | for searching in bookmarks | 55 | | contextMenus | for context menu for moving tab to a different container | 56 | | contextualIdentities | for working with tab containers | 57 | | cookies | for working with tab containers | 58 | | history | for showing history results in quick search | 59 | | menus | for creating context menus when moving tabs | 60 | | notifications | to show a warning if the tabhiding is not activated | 61 | | storage | for storing thumbnails | 62 | | tabs | for tab handling | 63 | | tabHide | for hiding and showing tabs | 64 | | webNavigation | for intercepting and reacting on new tabs | 65 | | webRequestBlocking | for intercepting and reacting on external links | 66 | webRequest | for intercepting and reacting on external links | 67 | -------------------------------------------------------------------------------- /conex-actions-common.js: -------------------------------------------------------------------------------- 1 | const renderTabContainers = async function(parent, topContainer) { 2 | const tabs = browser.tabs.query({audible: true}); 3 | const identities = browser.contextualIdentities.query({}); 4 | 5 | const newContainerMode = (await browser.browserAction.getBadgeText({})) == 'new'; 6 | browser.browserAction.setBadgeText({text: ''}); 7 | const identitiesWithAudibleContainers = (await tabs).map(x => x.cookieStoreId); 8 | 9 | const contexts = [{cookieStoreId: 'firefox-private', color: 'private', name: 'private browsing tabs'}, 10 | {cookieStoreId: 'firefox-default', color: 'default', name: 'default'}] 11 | .concat((await identities).sort((a,b) => a.name.toLowerCase() > b.name.toLowerCase())) 12 | 13 | if(topContainer) { 14 | const topElement = contexts.splice(contexts.findIndex(e => e.cookieStoreId == topContainer), 1); 15 | contexts.unshift(null); 16 | contexts.unshift(topElement[0]); 17 | } 18 | 19 | for(const context of contexts) { 20 | if(context == null) { 21 | parent.appendChild($e('br')); 22 | } else { 23 | parent.appendChild( 24 | createTabContainerHeaderElement( 25 | context.cookieStoreId, 26 | context.color, 27 | context.name, 28 | 1, 29 | '', 30 | identitiesWithAudibleContainers.includes(context.cookieStoreId))); 31 | } 32 | } 33 | 34 | return newContainerMode; 35 | } 36 | -------------------------------------------------------------------------------- /conex-background.js: -------------------------------------------------------------------------------- 1 | const imageQuality = 8; 2 | const defaultCookieStoreId = 'firefox-default'; 3 | const privateCookieStorePrefix = 'firefox-private'; 4 | const newTabs = new Set(); 5 | const newTabsUrls = new Map(); 6 | const newTabsTitles = new Map(); 7 | 8 | let lastCookieStoreId = defaultCookieStoreId; 9 | 10 | //////////////////////////////////// exported functions (es6 import / export stuff is not supported in webextensions) 11 | function interceptRequests() { 12 | if(typeof browser.webRequest == 'object') { 13 | console.info('set up request interceptor'); 14 | browser.webRequest.onBeforeRequest.addListener( 15 | showContainerSelectionOnNewTabs, 16 | { urls: [""], types: ["main_frame"] }, 17 | ["blocking"] 18 | ); 19 | } 20 | } 21 | 22 | function activateTab(tabId) { 23 | browser.tabs.update(Number(tabId), {active: true}).then(tab => { 24 | browser.windows.update(tab.windowId, {focused: true}); 25 | }, e => console.error(e)); 26 | } 27 | 28 | function refreshSettings() { 29 | readSettings = _refreshSettings(); 30 | } 31 | 32 | function closeTab(tabId) { 33 | browser.tabs.remove(Number(tabId)); 34 | } 35 | 36 | function newTabInCurrentContainer(url) { 37 | browser.tabs.query({active: true, windowId: browser.windows.WINDOW_ID_CURRENT}).then(tabs => { 38 | cookieStoreId = tabs.length > 0 ? tabs[0].cookieStoreId : defaultCookieStoreId; 39 | const createProperties = { 40 | cookieStoreId: cookieStoreId 41 | }; 42 | if(url) { 43 | createProperties['url'] = url 44 | } 45 | 46 | browser.tabs.create(createProperties).catch(e => console.error(e)); 47 | }, e => console.error(e)); 48 | } 49 | 50 | async function getTabsByContainer() { 51 | await readSettings; 52 | const containersTabsMap = {}; 53 | 54 | const tabs = browser.tabs.query({}); 55 | 56 | let bookmarkUrls = []; 57 | 58 | if(settings['search-bookmarks']) { 59 | const bookmarks = browser.bookmarks.search({}); 60 | try { 61 | bookmarkUrls = (await bookmarks).filter(b => b.url != undefined).map(b => b.url.toLowerCase()); 62 | } catch (e) { 63 | console.error('error querying bookmarks: ', e); 64 | } 65 | } 66 | 67 | for(const tab of (await tabs).sort((a,b) => b.lastAccessed - a.lastAccessed)) { 68 | const url = tab.url || ""; 69 | const thumbnailElement = createTabElement(tab, bookmarkUrls.indexOf(url.toLowerCase()) >= 0); 70 | 71 | if (!containersTabsMap[tab.cookieStoreId]) { 72 | containersTabsMap[tab.cookieStoreId] = []; 73 | } 74 | 75 | containersTabsMap[tab.cookieStoreId].push(thumbnailElement); 76 | } 77 | return containersTabsMap; 78 | } 79 | 80 | async function restoreTabContainersBackup(tabContainers, windows) { 81 | const identities = createMissingTabContainers(tabContainers); 82 | for (const tabs of windows) { 83 | const w = browser.windows.create({}); 84 | for (const tab of tabs) { 85 | if (!isBlessedUrl(tab.url)) { 86 | continue; 87 | } 88 | 89 | let cookieStoreId = defaultCookieStoreId; 90 | if (tab.container) { 91 | cookieStoreId = (await identities).get(tab.container.toLowerCase()); 92 | } 93 | 94 | const newTab = browser.tabs.create({ url: tab.url, cookieStoreId: cookieStoreId, windowId: (await w).id, active: false }); 95 | 96 | // we need to wait for the first onUpdated event before discarding, otherwise the tab is in limbo 97 | const onUpdatedHandler = async function(tabId, changeInfo) { 98 | if (tabId == (await newTab).id && changeInfo.status == "complete") { 99 | browser.tabs.onCreated.removeListener(onUpdatedHandler); 100 | browser.tabs.discard(tabId); 101 | } 102 | } 103 | 104 | browser.tabs.onUpdated.addListener(onUpdatedHandler); 105 | console.info(`creating tab ${tab.url} in container ${(await newTab).cookieStoreId} (cookieStoreId: ${cookieStoreId})`); 106 | } 107 | } 108 | } 109 | 110 | async function switchToContainer(cookieStoreId) { 111 | const tabs = await browser.tabs.query({windowId: browser.windows.WINDOW_ID_CURRENT, cookieStoreId: cookieStoreId}); 112 | if (tabs.length == 0) { 113 | const allTabs = await browser.tabs.query({windowId: browser.windows.WINDOW_ID_CURRENT}); 114 | if(allTabs.length > 0) { 115 | browser.tabs.create({ openerTabId: allTabs[0].id, cookieStoreId: cookieStoreId, active: true }); 116 | } else { 117 | console.error("did not find any active tab in window with id: ", browser.windows.WINDOW_ID_CURRENT); 118 | } 119 | } else { 120 | const lastAccessedTabs = tabs.sort((a, b) => b.lastAccessed - a.lastAccessed); 121 | 122 | // Try to switch to an unpinned tab, as switching a to pinned tab 123 | // will not update the visible tabs 124 | for (const tab of lastAccessedTabs) { 125 | if (!tab.pinned) { 126 | browser.tabs.update(tab.id, { active: true }); 127 | browser.windows.update(tab.windowId, { focused: true }); 128 | return; 129 | } 130 | } 131 | // All tabs in this container are pinned. Just switch to first one 132 | browser.tabs.update(lastAccessedTabs[0].id, { active: true }); 133 | browser.windows.update(lastAccessedTabs[0].windowId, { focused: true }); 134 | } 135 | } 136 | 137 | function openLinkInContainer(link, cookieStoreId) { 138 | browser.tabs.create({url: link, cookieStoreId: cookieStoreId}); 139 | } 140 | 141 | async function showCurrentContainerTabsOnly(activeTabId) { 142 | await readSettings; 143 | if(!settings["hide-tabs"] ) { 144 | return; 145 | } 146 | 147 | const activeTab = await browser.tabs.get(activeTabId); 148 | if(activeTab.pinned) { 149 | return; 150 | } 151 | 152 | showContainerTabsOnly(activeTab.cookieStoreId); 153 | } 154 | 155 | async function showContainerTabsOnly(cookieStoreId) { 156 | const allTabs = await browser.tabs.query({windowId: browser.windows.WINDOW_ID_CURRENT}); 157 | 158 | const visibleTabs = allTabs.filter(t => t.cookieStoreId == cookieStoreId).map(t => t.id); 159 | const hiddenTabs = allTabs.filter(t => t.cookieStoreId != cookieStoreId).map(t => t.id); 160 | 161 | // console.debug('visible tabs', visibleTabs, 'hidden tabs', hiddenTabs); 162 | 163 | try { 164 | showTabs(visibleTabs); 165 | hideTabs(hiddenTabs); 166 | } catch(e) { 167 | console.error('error showing / hiding tabs', e); 168 | } 169 | } 170 | 171 | async function openContainerSelector(url, title) { 172 | const tab = await browser.tabs.create({ 173 | active: true, 174 | cookieStoreId: defaultCookieStoreId, 175 | url: url, 176 | }); 177 | 178 | newTabsTitles.set(tab.id, title); 179 | } 180 | 181 | //////////////////////////////////// end of exported functions (again: es6 features not supported yet 182 | const menuId = function(s) { 183 | return `menu_id_for_${s}`; 184 | } 185 | 186 | const openInDifferentContainer = function(cookieStoreId, tab) { 187 | const tabProperties = { 188 | active: true, 189 | cookieStoreId: cookieStoreId, 190 | index: tab.index+1 191 | }; 192 | 193 | if(tab.url != 'about:newtab' && tab.url != 'about:blank') { 194 | tabProperties.url = tab.url; 195 | } 196 | 197 | browser.tabs.create(tabProperties); 198 | browser.tabs.remove(tab.id); 199 | } 200 | 201 | 202 | const createMissingTabContainers = async function(tabContainers) { 203 | const colors = ["blue", "turquoise", "green", "yellow", "orange", "red", "pink", "purple"]; 204 | 205 | const identities = await browser.contextualIdentities.query({}); 206 | 207 | const nameCookieStoreIdMap = new Map(identities.map(identity => [identity.name.toLowerCase(), identity.cookieStoreId])); 208 | const promises = []; 209 | 210 | for(const tabContainer of tabContainers) { 211 | if(!nameCookieStoreIdMap.get(tabContainer.toLowerCase())) { 212 | console.info(`creating tab container ${tabContainer}`); 213 | const newIdentity = {name: tabContainer, icon: 'circle', color: colors[Math.floor(Math.random() * (8 - 0)) + 0]}; 214 | const identity = await browser.contextualIdentities.create(newIdentity); 215 | 216 | nameCookieStoreIdMap.set(identity.name.toLowerCase(), identity.cookieStoreId); 217 | } 218 | } 219 | 220 | return nameCookieStoreIdMap; 221 | }; 222 | 223 | const isBlessedUrl = function(url) { 224 | return url.startsWith('http') || url.startsWith('about:blank') || url.startsWith('about:newtab'); 225 | } 226 | 227 | const showTabs = async function(tabIds) { 228 | browser.tabs.show(tabIds); 229 | } 230 | 231 | const hideTabs = async function(tabIds) { 232 | if(tabIds.length == 0) { 233 | return; 234 | } 235 | 236 | browser.tabs.hide(tabIds); 237 | } 238 | 239 | const updateLastCookieStoreId = function(activeInfo) { 240 | browser.tabs.get(activeInfo.tabId).then(tab => { 241 | if((tab.url != 'about:blank' || (tab.url == 'about:blank' && tab.cookieStoreId != defaultCookieStoreId)) 242 | && tab.cookieStoreId != lastCookieStoreId 243 | && !tab.cookieStoreId.startsWith(privateCookieStorePrefix)) { 244 | console.debug(`cookieStoreId changed from ${lastCookieStoreId} -> ${tab.cookieStoreId}`); 245 | lastCookieStoreId = tab.cookieStoreId; 246 | } 247 | }, e => console.error(`error setting cookieStoreId: ${e}`)); 248 | }; 249 | 250 | const storeScreenshot = async function(tabId, changeInfo, tab) { 251 | if(changeInfo.status != "complete" || tab.url == 'about:blank' || tab.url == browser.extension.getURL('container-selector.html')) { 252 | return; 253 | } 254 | readSettings; 255 | const cleanedUrl = cleanUrl(tab.url); 256 | 257 | try { 258 | let imageData = null; 259 | if(settings['create-thumbnail']) { 260 | console.debug(`capturing tab for ${cleanedUrl}`); 261 | imageData = await browser.tabs.captureTab(tab.id, { format: 'jpeg', quality: imageQuality }); 262 | console.debug(` capturing for ${cleanedUrl} finished`); 263 | } 264 | if(settings['show-favicons'] || settings['create-thumbnail']) { 265 | console.debug(` storing captured image for tab for ${cleanedUrl}`); 266 | // await browser.storage.local.set({ [cleanedUrl]: { thumbnail: imageData, favicon: tab.favIconUrl } }); 267 | console.debug(` succesfully created thumbnail for ${cleanedUrl}`); 268 | } 269 | // const favIconKey = `favicon:${cleanedUrl.split("/")[0]}`; 270 | // browser.storage.local.set({ [favIconKey]: { favicon: tab.favIconUrl } }); 271 | } catch(e) { 272 | console.error(`error creating tab screenshot: ${e}`); 273 | } 274 | }; 275 | 276 | const handleSettingsMigration = async function(details) { 277 | await readSettings; 278 | const currentVersion = 4; 279 | if(settings['settings-version'] == currentVersion) { 280 | return; 281 | } 282 | 283 | // old setting or first install: open the setting page 284 | if (settings['settings-version'] == undefined) { 285 | const settings = ['create-thumbnail', 'hide-tabs', 'search-bookmarks', 'search-history']; 286 | for(let setting of settings) { 287 | const settingId = 'conex/settings/' + setting; 288 | console.debug(`setting ${settingId} to false`); 289 | try { 290 | browser.storage.local.set({ [settingId]: false }); 291 | } catch(e) { 292 | console.error(`error persisting ${settingId}: ${e}`) 293 | } 294 | } 295 | } 296 | 297 | // setting version 1: tabHide was not optional 298 | if(settings['settings-version'] == 1) { 299 | try { 300 | console.log("migrating settings from version 1") 301 | const tabs = await browser.tabs.query({}); 302 | await browser.tabs.show(tabs.map(t => t.id)); 303 | await browser.storage.local.set({ 'conex/settings/hide-tabs': false }); 304 | await browser.permissions.remove({permissions: ['tabHide', 'notifications']}); 305 | await browser.storage.local.set({ 'conex/settings/settings-version': currentVersion }); 306 | } catch(e) { 307 | console.error(`error persisting settings: ${e}`) 308 | } 309 | } 310 | 311 | // setting version 2: no notifications necessary anymore 312 | if(settings['settings-version'] == 2) { 313 | try { 314 | await browser.permissions.remove({permissions: ['notifications']}); 315 | await browser.storage.local.set({ 'conex/settings/settings-version': currentVersion }); 316 | } catch(e) { 317 | console.error(`error persisting settings: ${e}`) 318 | } 319 | } 320 | 321 | // setting version 3: no notifications necessary anymore 322 | if(settings['settings-version'] == 3) { 323 | try { 324 | await browser.storage.local.set({ 'conex/settings/settings-version': currentVersion }); 325 | } catch(e) { 326 | console.error(`error persisting settings: ${e}`) 327 | } 328 | } 329 | 330 | 331 | try { 332 | await browser.storage.local.set({ 'conex/settings/settings-version': currentVersion }); 333 | } catch (e) { 334 | console.error(`error persisting ${settingId}: ${e}`) 335 | } 336 | 337 | refreshSettings(); 338 | await readSettings; 339 | browser.runtime.openOptionsPage(); 340 | } 341 | 342 | const showContainerSelectionOnNewTabs = async function(requestDetails) { 343 | if(requestDetails.tabId < 0) { 344 | return; 345 | } 346 | 347 | const tab = browser.tabs.get(requestDetails.tabId); 348 | 349 | if ((!requestDetails.originUrl || requestDetails.originUrl == browser.extension.getURL("")) && 350 | newTabs.has(requestDetails.tabId) && requestDetails.url.startsWith('http')) { 351 | if(settings['show-container-selector']) { 352 | console.debug('is new tab', newTabs.has(requestDetails.tabId), requestDetails, (await tab)); 353 | newTabsUrls.set(requestDetails.tabId, requestDetails.url); 354 | return { redirectUrl: browser.extension.getURL("container-selector.html") }; 355 | } else { 356 | console.debug('re-opening tab in ', lastCookieStoreId, (await tab)); 357 | browser.tabs.create({ 358 | active: (await tab).active, 359 | openerTabId: Number(requestDetails.tabId), 360 | cookieStoreId: lastCookieStoreId, 361 | url: requestDetails.url 362 | }); 363 | browser.tabs.remove(Number(requestDetails.tabId)); 364 | 365 | return { cancel: true }; 366 | } 367 | } else { 368 | return { cancel: false }; 369 | } 370 | }; 371 | 372 | const createContainerSelectorHTML = async function() { 373 | const main = document.body.appendChild($e('div', {id: 'main'}, [ 374 | $e('h2', { id: 'title' }), 375 | $e('span', {content: 'open in:'}), 376 | $e('div', {id: 'tabcontainers'}), 377 | $e('tt', { id: 'url' }) 378 | ])); 379 | 380 | document.body.appendChild(main); 381 | const tabContainers = $1("#tabcontainers"); 382 | await renderTabContainers(tabContainers, lastCookieStoreId); 383 | const src = $1('#main').innerHTML; 384 | document.body.removeChild($1('#main')); 385 | 386 | return src.replace(/(\r\n|\n|\r)/gm,""); 387 | } 388 | 389 | const fillContainerSelector = async function(details) { 390 | if(details.url == browser.extension.getURL("container-selector.html")) { 391 | const url = newTabsUrls.get(details.tabId).replace(/'/g, "\\\'"); 392 | newTabsUrls.delete(details.tabId); 393 | 394 | const title = newTabsTitles.get(details.tabId) ? newTabsTitles.get(details.tabId) : ''; 395 | newTabsTitles.delete(details.tabId); 396 | 397 | browser.tabs.executeScript(details.tabId, {code: 398 | `const port = browser.runtime.connect(); \ 399 | document.querySelector('#main').innerHTML = '${await createContainerSelectorHTML()}'; \ 400 | document.querySelector('#title').innerHTML = '${title}'; \ 401 | document.querySelector('#url').innerHTML = '${url}'; \ 402 | document.title = '${url}'; \ 403 | for(const ul of document.querySelectorAll('#tabcontainers ul')) { \ 404 | const post = _ => port.postMessage({tabId: '${details.tabId}', url: '${url}', container: ul.id}); 405 | ul.addEventListener('click', post); 406 | ul.addEventListener('keypress', e => { 407 | if(e.key == 'Enter') { post(); } 408 | }); 409 | }`}); 410 | browser.history.deleteUrl({url: browser.extension.getURL("container-selector.html")}); 411 | } 412 | } 413 | 414 | const closeIfReopened = async function(tab) { 415 | if(!settings['close-reopened-tabs']) { 416 | return; 417 | } 418 | 419 | const title = tab.title; 420 | const index = tab.index; 421 | const potentialOpenerIndex = index - 1; 422 | 423 | if(potentialOpenerIndex < 0) { 424 | return; 425 | } 426 | 427 | try { 428 | const potentialOpeners = await browser.tabs.query({index: potentialOpenerIndex}); 429 | if(potentialOpeners.length > 0) { 430 | if(potentialOpeners[0].url.includes(title)) { 431 | console.info("detected re-opening of", potentialOpeners[0], " ... closing original tab"); 432 | await browser.tabs.remove(potentialOpeners[0].id); 433 | showCurrentContainerTabsOnly(tab.id); 434 | } 435 | } 436 | } catch(e) { console.debug(`error closing reopened tab with index: ${potentialOpenerIndex}, url: ${title}: ${e}`); } 437 | } 438 | 439 | const openIncognito = async function(url) { 440 | const windows = await browser.windows.getAll(); 441 | for(const window of windows) { 442 | if(window.incognito) { 443 | await browser.windows.update(window.id, {focused: true}); 444 | try { 445 | await browser.tabs.create({ 446 | active: true, 447 | url: url, 448 | windowId: window.id}); 449 | console.debug("incognito tab created"); 450 | } catch(e) { console.error("error creating new tab: ", e) }; 451 | return; 452 | } 453 | } 454 | 455 | try { 456 | browser.windows.create({ 457 | url: url, 458 | incognito: true 459 | })} catch(e) { console.error("error creating private window: ", e)}; 460 | } 461 | 462 | browser.runtime.onConnect.addListener(function(p){ 463 | p.onMessage.addListener(function(msg) { 464 | if(msg.container == "firefox-private") { 465 | openIncognito(msg.url).then(browser.tabs.remove(Number(msg.tabId))); 466 | } else { 467 | browser.tabs.create({ 468 | active: true, 469 | openerTabId: Number(msg.tabId), 470 | cookieStoreId: msg.container, 471 | url: msg.url 472 | }).then(_ => browser.tabs.remove(Number(msg.tabId)), 473 | e => console.error("error creating new tab: ", e)); 474 | } 475 | }); 476 | }); 477 | 478 | /////////////////////////// setup listeners 479 | browser.runtime.onInstalled.addListener(handleSettingsMigration); 480 | 481 | browser.webNavigation.onCompleted.addListener(fillContainerSelector); 482 | 483 | browser.tabs.onCreated.addListener(tab => { 484 | if(tab.url == 'about:newtab' && tab.openerTabId == undefined && tab.cookieStoreId == defaultCookieStoreId && lastCookieStoreId != defaultCookieStoreId) { 485 | openInDifferentContainer(lastCookieStoreId, tab); 486 | } 487 | }); 488 | 489 | browser.tabs.onCreated.addListener(tab => { 490 | if(tab.url == 'about:blank') { 491 | closeIfReopened(tab); 492 | } 493 | }); 494 | 495 | browser.tabs.onCreated.addListener(tab => { 496 | if(tab.url == 'about:blank' && tab.openerTabId == undefined && tab.cookieStoreId == defaultCookieStoreId) { 497 | newTabs.add(tab.id); 498 | } 499 | }); 500 | 501 | browser.windows.onFocusChanged.addListener(windowId => { 502 | browser.tabs.query({active: true, windowId: windowId}).then(tabs => { 503 | if(tabs.length > 0) { 504 | lastCookieStoreId = tabs[0].cookieStoreId; 505 | } 506 | }); 507 | }); 508 | 509 | browser.tabs.onUpdated.addListener(tab => newTabs.delete(tab.id)); 510 | browser.tabs.onActivated.addListener(updateLastCookieStoreId); 511 | browser.tabs.onActivated.addListener(function(activeInfo) { 512 | showCurrentContainerTabsOnly(activeInfo.tabId); 513 | }); 514 | 515 | browser.tabs.onUpdated.addListener(storeScreenshot); 516 | 517 | interceptRequests(); 518 | browser.menus.create({ id: "settings", title: "Conex settings", onclick: function() {browser.runtime.openOptionsPage(); }, 519 | contexts: ["browser_action"]}); 520 | 521 | browser.tabs.query({active: true, windowId: browser.windows.WINDOW_ID_CURRENT}).then(tabs => { 522 | if(tabs.length > 0) { 523 | lastCookieStoreId = tabs[0].cookieStoreId; 524 | } 525 | }); 526 | 527 | browser.commands.onCommand.addListener(function(command) { 528 | console.debug("command called", command); 529 | if(command == "new-container") { 530 | browser.browserAction.setBadgeText({text: "new"}); 531 | browser.browserAction.openPopup(); 532 | } 533 | }); 534 | 535 | console.info('conex loaded'); 536 | -------------------------------------------------------------------------------- /conex-browser-action.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | + 13 |
14 |
15 |
16 | 17 | 27 | 28 |
29 |
30 |
31 |
32 |
33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /conex-browser-action.js: -------------------------------------------------------------------------------- 1 | const deletedTabOpacity = 0.3; 2 | const containersTabsMapCreating = bg.getTabsByContainer(); 3 | const tabContainerRendering = renderTabContainers($1('#tabcontainers')); 4 | 5 | 6 | const keyDownHandling = function(event) { 7 | //console.debug('keydown', event, document.activeElement); 8 | const searchElement = $1('#search'); 9 | 10 | if(event.key == 'ArrowUp') { 11 | console.debug('arrowup'); 12 | let prevElement = searchElement; 13 | 14 | for(e of $('li')) { 15 | if(e.dataset.match == 'true' && e.style.display != 'none') { 16 | if(e == document.activeElement) { 17 | prevElement.focus() 18 | break; 19 | } else { 20 | prevElement = e; 21 | } 22 | } 23 | } 24 | } else if(event.key == 'ArrowDown') { 25 | console.debug('arrowdown'); 26 | let foundActiveElement = document.activeElement == searchElement || document.activeElement == $1('body'); 27 | 28 | for(e of $('li')) { 29 | if(e.dataset.match == 'true' && e.style.display != 'none') { 30 | if(e == document.activeElement) { 31 | foundActiveElement = true; 32 | } else { 33 | if(foundActiveElement) { 34 | e.focus(); 35 | break; 36 | } 37 | } 38 | } 39 | } 40 | } else if(event.target.id == 'search' && event.ctrlKey && event.key == '+') { 41 | showNewContainerUi(); 42 | } else if(document.activeElement.dataset.cookieStore && event.ctrlKey && event.key == '+' ) { // a container section / ctrl+'+' 43 | newTabInContainer(document.activeElement.dataset.cookieStore); 44 | } else if(document.activeElement.dataset.cookieStore && event.ctrlKey && event.shiftKey && event.key == 'Enter' ) { // a container section / ctrl+enter+shift 45 | browser.tabs.create({cookieStoreId: document.activeElement.dataset.cookieStore, active: true}); 46 | window.close(); 47 | } else if(document.activeElement.dataset.cookieStore && (event.key == 'ArrowRight' || event.key == 'ArrowLeft')) { // a container section 48 | toggleExpandTabContainer(document.activeElement.dataset.cookieStore); 49 | return; 50 | } else if(event.key == 'Backspace') { 51 | if(document.activeElement.dataset.tabId) { // delete tab 52 | removeTab(document.activeElement); 53 | } else if(document.activeElement.dataset.cookieStore) { // delete container 54 | if(event.shiftKey) { 55 | const doubleClickEvent = document.createEvent('MouseEvents'); 56 | doubleClickEvent.initEvent('dblclick', true, true); 57 | $1('.delete-container-button', event.target).dispatchEvent(doubleClickEvent); 58 | } else { 59 | $1('.delete-container-button', event.target).click(); 60 | } 61 | } 62 | } else if(event.key == 'Enter') { 63 | return false; 64 | } 65 | } 66 | 67 | const keyPressHandling = function(event) { 68 | //console.debug('keypress', event, document.activeElement); 69 | const searchElement = $1('#search'); 70 | 71 | if(event.key == 'Enter') { 72 | try { 73 | if(document.activeElement.dataset.tabId && document.activeElement.dataset.tabId > 0) { // a normal tab 74 | bg.activateTab(document.activeElement.dataset.tabId); 75 | } else if(document.activeElement.dataset.url) { // a history or bookmark entry 76 | bg.openContainerSelector(document.activeElement.dataset.url, document.activeElement.dataset.title); 77 | } else if(document.activeElement.dataset.cookieStore && event.ctrlKey && event.shiftKey ) { // a container section / ctrl+enter+shift 78 | browser.tabs.create({cookieStoreId: document.activeElement.dataset.cookieStore, active: true}); 79 | } else if(document.activeElement.dataset.cookieStore && event.ctrlKey) { // a container section / ctrl+enter 80 | toggleExpandTabContainer(document.activeElement.dataset.cookieStore); 81 | return; 82 | } else if(document.activeElement.dataset.cookieStore) { // a container section 83 | bg.switchToContainer(document.activeElement.dataset.cookieStore); 84 | } else if(document.activeElement.id == 'search') { // enter in search form == activate first shown container or tab 85 | let candidate = undefined; // if we enter a section, remember this section but try to find a matching tab in this section first 86 | 87 | for(const e of $('li')) { 88 | if(e.style.display != 'none' && e.dataset.tabId) { 89 | if(e.dataset.tabId == 0) { // history or bookmark entry 90 | console.log('restoringTab', e.dataset.url, e); 91 | bg.openContainerSelector(e.dataset.url, e.dataset.title); 92 | } else { // a tab that is open 93 | console.log(`activateTab ${e.dataset.tabId}`, e); 94 | bg.activateTab(e.dataset.tabId); 95 | } 96 | candidate = undefined; 97 | break; 98 | } else if(e.style.display != 'none' && e.className == 'section' && e.dataset.match == 'true') { 99 | if(candidate) { 100 | console.log('switchToContainer', candidate); 101 | bg.switchToContainer(candidate); 102 | candidate = undefined; 103 | break; 104 | } 105 | candidate = e.dataset.cookieStore; 106 | } 107 | } 108 | if(candidate) { 109 | console.log('switchToContainer', candidate); 110 | bg.switchToContainer(candidate); 111 | } 112 | } else { 113 | console.error('unhandled keypress active element:', document.activeElement); 114 | } 115 | window.close(); 116 | } catch(e){console.error(e);} 117 | } else if(event.key == 'Backspace' && document.activeElement.dataset.tabId) { // close tab 118 | removeTab(document.activeElement); 119 | } else if(event.key == 'Tab') { // needed to eat the tab event 120 | } else if(document.activeElement != searchElement) { 121 | if(event.key == 'ArrowRight' || event.key == 'ArrowLeft') { 122 | return; 123 | } 124 | searchElement.focus(); 125 | searchElement.value = ''; 126 | if(event.key.length == 1) { 127 | searchElement.value = event.key; 128 | } 129 | } 130 | }; 131 | 132 | const newTabInContainer = function(cookieStoreId) { 133 | browser.tabs.query({active: true, windowId: browser.windows.WINDOW_ID_CURRENT}).then(tabs => { 134 | if(tabs.length > 0) { 135 | browser.tabs.create({cookieStoreId: cookieStoreId, active: true, openerTabId: tabs[0].id }).then( 136 | _ => window.close(), 137 | e => console.error('error creating new tab: ', e)); 138 | } else { 139 | console.error("did not find any active tab in window with id: ", browser.windows.WINDOW_ID_CURRENT); 140 | } 141 | }, e => console.error('error getting current tab: ', e)); 142 | }; 143 | 144 | const toggleExpandTabContainer = function(cookieStoreId) { 145 | const tabContainer = $1(`ul#${cookieStoreId}`); 146 | if(tabContainer.dataset.expanded == "true") { 147 | { 148 | const e = $1('.arrow-down', tabContainer); 149 | if(e) { e.className = 'arrow-right'; } 150 | } 151 | for(const element of $(`ul#${cookieStoreId} li.tab`)) { 152 | element.style.display = 'none'; 153 | } 154 | tabContainer.dataset.expanded = 'false'; 155 | } else { 156 | { 157 | const e = $1('.arrow-right', tabContainer); 158 | if(e) { e.className = 'arrow-down'; } 159 | } 160 | for(const element of $(`ul#${cookieStoreId} li.tab`)) { 161 | const thumbnailElement = $1('.image', element); 162 | if(thumbnailElement && thumbnailElement.dataset.bgSet == 'false') { 163 | setBgImage(thumbnailElement, element.dataset.url); 164 | } 165 | element.style.display = element.dataset.match == 'true' ? '' : 'none'; 166 | } 167 | tabContainer.dataset.expanded = 'true'; 168 | } 169 | 170 | } 171 | 172 | document.body.addEventListener('keypress', keyPressHandling); 173 | document.body.addEventListener('keydown', keyDownHandling); 174 | 175 | const removeTab = function(element) { 176 | element.style.opacity = deletedTabOpacity; 177 | element.tabIndex = -1; 178 | bg.closeTab(element.dataset.tabId); 179 | updateTabCount(); 180 | } 181 | 182 | const insertTabElements = function(tabContainers) { 183 | for(tabContainer in tabContainers) { 184 | const ul = $1(`#${tabContainer}`); 185 | if(!ul) { 186 | console.error(`couldn't find tab container with id ${tabContainer} -- closing all tabs from this container`); 187 | for(const element of tabContainers[tabContainer]) { 188 | console.debug(` closing tab from non-existing container: ${element.dataset.url}`); 189 | bg.closeTab(element.dataset.tabId); 190 | } 191 | continue; 192 | } 193 | 194 | let cnt = 0; 195 | for(const element of tabContainers[tabContainer]) { 196 | element.addEventListener('click', _ => { 197 | bg.activateTab(element.dataset.tabId); 198 | window.close(); 199 | }); 200 | 201 | $1('.close-button', element).addEventListener('click', function(event) { 202 | event.stopPropagation(); 203 | removeTab(element); 204 | return false; 205 | }); 206 | 207 | ul.appendChild(element); 208 | cnt++; 209 | } 210 | } 211 | updateTabCount(); 212 | }; 213 | 214 | const tabIsDeleted = function(e) { 215 | return e.style.opacity == deletedTabOpacity; 216 | } 217 | 218 | const updateTabCount = function() { 219 | console.debug('updating tab count'); 220 | for(const tabContainer of $('#tabcontainers ul')) { 221 | const tabCnt = Array.from($('li.tab', tabContainer)).filter(e => !tabIsDeleted(e)).length; 222 | const tabCntElement = $1('.tabs-count', tabContainer); 223 | tabCntElement.removeChild(tabCntElement.firstChild); 224 | tabCntElement.appendChild(document.createTextNode(`(${tabCnt} tabs)`)); 225 | $1('.name', tabContainer).title = `change to this container (${tabCnt} tabs)`; 226 | $1('.confirmation-tabs-count', tabContainer).innerHTML = tabContainer.id == 'firefox-default' ? `Are you sure you want to close all ${tabCnt} tabs in the default container ?` : `If you remove this container now, ${tabCnt} tabs will be closed. Are you sure you want to remove this container?`; 227 | 228 | // hide private browsing tabs section if we don't have any private tabs open 229 | if(tabCnt == 0 && tabContainer.id == "firefox-private") { 230 | tabContainer.remove(); 231 | } 232 | } 233 | } 234 | 235 | const resetPopup = function() { 236 | { const history = $1('#history ul'); if(history) { history.remove() }} 237 | { const bookmarks = $1('#bookmarks ul'); if(bookmarks) { bookmarks.remove(); }} 238 | for(ul of $('#tabcontainers ul')) { 239 | ul.style.display = ''; 240 | 241 | { 242 | const arrowDown = $1('.arrow-down', ul); 243 | if(arrowDown) { 244 | arrowDown.className = 'arrow-right'; 245 | } 246 | } 247 | } 248 | 249 | for(li of $('#tabcontainers li.tab')) { 250 | li.style.display = 'none'; 251 | } 252 | }; 253 | 254 | const renderResults = function(results, parent) { 255 | const tabLinks = Array.from($('.tab')).map(t => t.dataset.url.toLowerCase()); 256 | 257 | { 258 | const arrowRight = $1('.arrow-right', parent); 259 | if (arrowRight) { 260 | arrowRight.className = 'arrow-down'; 261 | } 262 | } 263 | 264 | results 265 | .sort((a,b) => b.visitCount - a.visitCount) 266 | .filter(e => e.url && ! tabLinks.includes(cleanUrl(e.url))) 267 | .forEach(searchResult => { 268 | const element = createHistoryOrBookmarkElement(searchResult); 269 | const thumbnailElement = $1('.image', element); 270 | if(thumbnailElement && thumbnailElement.dataset.bgSet == 'false') { 271 | setBgImage(thumbnailElement, element.dataset.url); 272 | } 273 | parent.appendChild(element); 274 | }); 275 | } 276 | 277 | const fillBookmarksSection = function(searchQuery) { 278 | const bookmarks = $1('#bookmarks'); 279 | if(bookmarks.children.length > 0) { return; } 280 | 281 | const tabContainerHeader = createTabContainerHeaderElement('', 'bookmarks', 'bookmarks', -1, '★ '); 282 | 283 | browser.bookmarks.search({ 284 | query: searchQuery 285 | }).then(results => { 286 | renderResults(results, tabContainerHeader); 287 | 288 | if ($1('ul', bookmarks)) { 289 | $1('ul', bookmarks).replaceWith(tabContainerHeader); 290 | } else { 291 | bookmarks.appendChild(tabContainerHeader); 292 | } 293 | }, e => console.error(`Error searching ${searchQuery}: `, e)); 294 | }; 295 | 296 | const fillHistorySection = function(searchQuery) { 297 | const history = $1('#history'); 298 | if(history.children.length > 0) { $1('ul', history).remove(); } 299 | const tabContainerHeader = createTabContainerHeaderElement('', 'history', 'history', -1); 300 | 301 | browser.history.search({ 302 | text: searchQuery, 303 | maxResults: 30, 304 | startTime: 0 305 | }).then(results => { 306 | renderResults(results, tabContainerHeader); 307 | 308 | if ($1('ul', history)) { 309 | $1('ul', history).replaceWith(tabContainerHeader); 310 | } else { 311 | history.appendChild(tabContainerHeader); 312 | } 313 | }, e => console.error(`Error searching ${searchQuery}: `, e)); 314 | }; 315 | 316 | const setBgImage = async function(element, url) { 317 | await readSettings; 318 | if(settings['create-thumbnail']) { 319 | const cleanedUrl = cleanUrl(url); 320 | 321 | element.dataset.bgSet = 'true'; 322 | const cachedThumbnails = await browser.storage.local.get(cleanedUrl); 323 | if (cachedThumbnails[cleanedUrl] && cachedThumbnails[cleanedUrl].thumbnail) { 324 | element.style.background = "url(" + cachedThumbnails[cleanedUrl].thumbnail + ")"; 325 | } 326 | } 327 | } 328 | 329 | const showHideTabEntries = function(searchQuery) { 330 | for(element of $('.tab')) { 331 | const text = (element.dataset.title + cleanUrl(element.dataset.url)).toLowerCase(); 332 | 333 | if(text) { 334 | // if the search query consists of multiple words, check if ALL words match -- regardless of the order 335 | const match = searchQuery.split(' ').every(q => { 336 | return text.indexOf(q) >= 0 337 | }); 338 | 339 | if(match) { 340 | const thumbnailElement = $1('.image', element); 341 | if(thumbnailElement && thumbnailElement.dataset.bgSet == 'false') { 342 | setBgImage(thumbnailElement, element.dataset.url); 343 | } 344 | } 345 | element.style.display = match ? '' : 'none'; 346 | element.dataset.match = match; 347 | } 348 | } 349 | }; 350 | 351 | const showHideTabContainerHeader = function(searchQuery) { 352 | for(ul of $('ul')) { 353 | const tabContainerHeader = ul.querySelector('li.section'); 354 | const text = $1('.name', tabContainerHeader).innerText.toLowerCase(); 355 | 356 | // if the search query consists of multiple words, check if ALL words match -- regardless of the order 357 | const match = searchQuery.split(' ').every(q => { 358 | return text.indexOf(q) >= 0 359 | }); 360 | 361 | if(match) { // don't hide header if it matches the current search 362 | ul.style.display = ''; 363 | tabContainerHeader.dataset.match = 'true'; 364 | } else { 365 | ul.dataset.expanded = 'true'; 366 | 367 | { 368 | const arrowDown = $1('.arrow-right', tabContainerHeader); 369 | if(arrowDown) { 370 | arrowDown.className = 'arrow-down'; 371 | } 372 | } 373 | 374 | // hide sections that don't have tabs that match the search 375 | if(Array.from(ul.querySelectorAll('li.tab')).filter(li => li.style.display != 'none') == 0) { 376 | ul.style.display = 'none'; 377 | tabContainerHeader.dataset.match = 'false'; 378 | } else { 379 | ul.style.display = ''; 380 | tabContainerHeader.dataset.match = 'true'; 381 | } 382 | } 383 | } 384 | }; 385 | 386 | const onSearchChange = function(event) { 387 | let searchQuery = ""; 388 | if(event.type == "paste") { 389 | searchQuery = event.clipboardData.getData("text").toLowerCase(); 390 | } else { 391 | searchQuery = event.target.value.toLowerCase(); 392 | } 393 | 394 | if(searchQuery == '') { 395 | return resetPopup(); 396 | } 397 | 398 | showHideTabEntries(searchQuery); 399 | showHideTabContainerHeader(searchQuery); 400 | 401 | if(settings["search-history"]) { 402 | fillHistorySection(searchQuery); 403 | } 404 | 405 | if(settings["search-bookmarks"]) { 406 | fillBookmarksSection(searchQuery); 407 | } 408 | }; 409 | 410 | const createNewContainer = function() { 411 | const name = $1('#new-container-name').value; 412 | const color = $1('#color').options[$1('#color').options.selectedIndex].className; 413 | 414 | if(name == "") { 415 | return; 416 | } 417 | 418 | console.debug(`creating new container ${name} with color ${color}`); 419 | browser.contextualIdentities.create({ 420 | name: name, 421 | color: color, 422 | icon: 'circle' 423 | }).then(newContainer => { 424 | console.info('successfully created container ', newContainer); 425 | bg.switchToContainer(newContainer.cookieStoreId); 426 | $1('body').className = ''; 427 | window.close(); 428 | }, e => console.error('error creating new container:', e)); 429 | }; 430 | 431 | const setupNewContainerElement = async function() { 432 | $1('#color').addEventListener('change', e => { 433 | $1('#color').className = e.target.options[e.target.options.selectedIndex].className; 434 | }); 435 | $1('#color').options.selectedIndex = 0; 436 | $1('#color').className = $1('#color').options[0].className; 437 | $1('#new-container-form').addEventListener('submit', e => { 438 | createNewContainer(); 439 | }); 440 | }; 441 | 442 | const deleteContainer = (cookieStoreId, name) => { 443 | browser.contextualIdentities.remove(cookieStoreId); 444 | window.close(); 445 | }; 446 | 447 | const setupSectionListeners = function() { 448 | for(const section of $('.section')) { 449 | $1('.icon', section).addEventListener('click', _ => { 450 | toggleExpandTabContainer(section.dataset.cookieStore); 451 | }); 452 | 453 | $1('.new-tab-button', section).addEventListener('click', _ => { 454 | newTabInContainer(section.dataset.cookieStore); 455 | }); 456 | 457 | const deleteContainerHandler = function(e, force) { 458 | const cookieStoreId = e.target.dataset.cookieStore; 459 | const name = e.target.dataset.name; 460 | browser.tabs.query({cookieStoreId: cookieStoreId}).then(tabs => { 461 | if(tabs.length > 0 && !force) { 462 | e.target.parentElement.classList.add('confirming'); 463 | } else if(tabs.length > 0 && force) { 464 | deleteContainerWithTabs(e.target.parentElement.dataset); 465 | } else { 466 | deleteContainer(cookieStoreId, name); 467 | } 468 | }); 469 | }; 470 | 471 | $1('.delete-container-button', section).addEventListener('click', e => { 472 | deleteContainerHandler(e, false); 473 | }); 474 | 475 | $1('.delete-container-button', section).addEventListener('dblclick', e => { 476 | deleteContainerHandler(e, true); 477 | }); 478 | 479 | $1('.no', section).addEventListener('click', e => { 480 | e.target.parentElement.parentElement.classList.remove('confirming'); 481 | }); 482 | 483 | const deleteContainerWithTabs = async function(dataset) { 484 | const cookieStoreId = dataset.cookieStore; 485 | const containerTabs = await browser.tabs.query({cookieStoreId: cookieStoreId}); 486 | await browser.tabs.remove(containerTabs.map(x => x.id)); 487 | deleteContainer(cookieStoreId, dataset.name); 488 | } 489 | 490 | $1('.yes', section).addEventListener('click', e => { 491 | deleteContainerWithTabs(e.target.parentElement.parentElement.dataset); 492 | }); 493 | 494 | $1('.name', section).addEventListener('click', _ => { 495 | bg.switchToContainer(section.dataset.cookieStore); 496 | window.close(); 497 | }); 498 | } 499 | }; 500 | 501 | const showNewContainerUi = function() { 502 | $1('body').className = 'new-container'; 503 | $1('#new-container-name').focus(); 504 | }; 505 | 506 | const startTime = Date.now(); 507 | tabContainerRendering.then(newContainerMode => { 508 | if(newContainerMode) { 509 | showNewContainerUi(); 510 | } 511 | 512 | setupSectionListeners(); 513 | 514 | containersTabsMapCreating.then(containerTabs => { 515 | insertTabElements(containerTabs); 516 | }, e => console.error(e)); 517 | 518 | $1('#new-container-form').addEventListener('keypress', e => { 519 | if(e.key == 'Enter') { 520 | createNewContainer(); 521 | } 522 | }); 523 | 524 | $1('#search').addEventListener('keyup', onSearchChange); 525 | $1('#search').addEventListener('paste', onSearchChange); 526 | console.debug("rendering time: ", Date.now() - startTime); 527 | const mouseMoveListener = function() { 528 | try { $1('body').removeEventListener('mousemove', mouseMoveListener); } catch (e) { console.error(e); }; 529 | } 530 | $1('body').addEventListener('mousemove', mouseMoveListener); 531 | 532 | $1('#new-container-button').addEventListener('click', showNewContainerUi); 533 | 534 | setupNewContainerElement(); 535 | 536 | }, e => console.error(e)); 537 | -------------------------------------------------------------------------------- /conex-components.js: -------------------------------------------------------------------------------- 1 | const bg = browser.extension.getBackgroundPage(); 2 | 3 | // creates a dom element, can contain children; attributes contains a map of the elements attributes 4 | // with 'content' being a special attribute representing the text node's content; underscores in 5 | // keys will be changed to dashes 6 | // 7 | // $e('div', {class: 'foo'}, [ 8 | // $e('span', {class: 'bar1', data_foo: 'bar', content: 'baz1'}), 9 | // $e('span', {class: 'bar2', content: 'baz2y}) 10 | // ]); 11 | // 12 | // will produce: 13 | // 14 | //
baz1baz2
15 | // 16 | const $e = function(name, attributes, children) { 17 | const e = document.createElement(name); 18 | for(const key in attributes) { 19 | if(key == 'content') { 20 | e.appendChild(document.createTextNode(attributes[key])); 21 | } else { 22 | e.setAttribute(key.replace(/_/g, '-'), attributes[key]); 23 | } 24 | } 25 | 26 | for(const child of children || []) { 27 | e.appendChild(child); 28 | } 29 | 30 | return e; 31 | }; 32 | 33 | function createHeaderElement(value) { 34 | return $e('h2', {content: value}); 35 | } 36 | 37 | function createTabContainerHeaderElement(id, color, name, tabindex, icon, containsAudibleTab) { 38 | let iconElement = $e('span', {class: 'icon', title: 'expand container list'}, [$e('span', {class: 'arrow-right'})]); 39 | if(color == "bookmarks" || color == "history") { 40 | iconElement = $e('span', {class: 'icon'}, [$e('span', { class: `icon-${color}`, content: icon || ' ' })]); 41 | } 42 | const tooltip = id == 'firefox-default' ? 'close all tabs' : 'delete this container'; 43 | const data_match = id == '' ? 'false' : 'true'; 44 | 45 | const elment = 46 | $e('ul', {id: id, data_expanded: 'false', class: color}, [ 47 | $e('li', {tabindex: tabindex || 1, class: 'section', data_match: data_match, data_name: name, data_cookie_store: id, title: 'enter: to expand\nctrl-enter: switch to container\nctrl-shift-enter: new tab in container'}, [ 48 | $e('div', {class: 'delete-container-confirmation'}, [ 49 | $e('span', {class: 'confirmation-tabs-count'}), 50 | $e('span', {content: 'yes', class: 'yes', title: 'yes, delete container on all its tabs'}), 51 | $e('span', {content: 'no', class: 'no', title: "abort mission -- abort mission!"}), 52 | ]), 53 | iconElement, 54 | $e('span', { class: 'name', title: 'change to this container (x tabs)', content: name }), 55 | $e('img', { class: `audible-${containsAudibleTab}`, src: 'icons/loudspeaker.svg'}), 56 | $e('span', { class: 'tabs-count', content: '(x tabs)'}), 57 | $e('span', { class: 'toolbar new-tab-button', title: 'open new tab in this container', content: '+'}), 58 | $e('span', { class: 'toolbar delete-container-button', data_name: name, data_cookie_store: id, title: tooltip, content: 'x'}) 59 | ]) 60 | ]); 61 | 62 | return elment; 63 | } 64 | 65 | function createTabElement(tab, isBookmarkUrl) { 66 | if(!tab.id || tab.id == browser.tabs.TAB_ID_NONE) { 67 | return; 68 | } 69 | 70 | return renderEntry(tab.url ? cleanUrl(tab.url) : '', 71 | tab.title ? tab.title : '', 72 | tab.id, 73 | tab.favIconUrl, 74 | isBookmarkUrl, 75 | tab.audible 76 | ); 77 | } 78 | 79 | function createHistoryOrBookmarkElement(historyItem) { 80 | const favIconUrl = (new URL(historyItem.url)).protocol + '//' + (new URL(historyItem.url)).host + '/favicon.ico'; 81 | const element = renderEntry(historyItem.url, historyItem.title.toLowerCase(), 0, favIconUrl); 82 | element.style.display = ""; 83 | 84 | element.addEventListener('click', _ => { 85 | bg.openContainerSelector(element.dataset.url, element.dataset.title); 86 | window.close(); 87 | } 88 | ); 89 | 90 | return element; 91 | } 92 | 93 | const renderEntry = function(url, title, id, favIconUrl, drawBookmarkIcon, drawAudibleIcon) { 94 | const isHistoryOrBookmark = (id == 0); 95 | const defaultFavIconUrl = './favicon.ico'; 96 | const elClass = drawBookmarkIcon ? 'tab is-bookmark' : 'tab'; 97 | const searchTerm = '${title} ${url.toLowerCase()}'; 98 | 99 | const tooltip = isHistoryOrBookmark ? 'enter: re-open tab' : 'enter: jump to tab\nbackspace: close tab'; 100 | 101 | let thumbnailElement = $e('span'); 102 | if(bg.settings['show-favicons']) { 103 | thumbnailElement = $e('img', { src: defaultFavIconUrl, class: 'no-thumbnail' }); 104 | } 105 | if(bg.settings['create-thumbnail']) { 106 | thumbnailElement = $e('div', { class: 'image', data_bg_set: 'false', style: `background:url('${defaultFavIconUrl}')` }, [ 107 | $e('img', { src: defaultFavIconUrl }) 108 | ]); 109 | } 110 | 111 | const element = 112 | $e('li', {tabindex: 1, class: elClass, data_match: 'true', data_title: title.toLowerCase(), data_url: url, data_tab_id: id, 113 | style: 'display:none', title: tooltip } ,[ 114 | $e('div', {}, [ 115 | thumbnailElement, 116 | $e('div', {class: 'text'}, [ 117 | $e('div', {class: 'tab-title', content: title}), 118 | $e('div', {class: 'tab-url', content: url}) 119 | ]), 120 | $e('div', {class: 'close', style: isHistoryOrBookmark ? 'display: none' : ''}, [ 121 | $e('span', {content: '★', title: 'this tab is a bookmark', class: 'bookmark-marker', data_tab_id: id}), 122 | $e('span', {content: '╳', title: 'close this tab', class: 'close-button', data_tab_id: id}), 123 | $e('img', {src: 'icons/loudspeaker.svg', title: 'this tab is playing audio', class: `audible-${drawAudibleIcon}`}), 124 | ]) 125 | ]), 126 | ]); 127 | 128 | if (bg.settings['show-favicons']) { 129 | const imgElement = $1('img', element) 130 | setFavIcon(url, favIconUrl, imgElement); 131 | } 132 | 133 | return element; 134 | } 135 | 136 | const setFavIcon = async function(url, favIconUrl, imgElement) { 137 | const defaultFavIconUrl = './favicon.ico'; 138 | if(favIconUrl && favIconUrl.startsWith('data:image')) { 139 | imgElement.src = favIconUrl; 140 | return; 141 | } 142 | 143 | const cleanedUrl = cleanUrl(url); 144 | try { 145 | const cache = await browser.storage.local.get(cleanedUrl); 146 | if (cache[cleanedUrl] && cache[cleanedUrl].favicon) { 147 | imgElement.src = cache[cleanedUrl].favicon; 148 | return; 149 | } 150 | } catch(e) { console.debug(`error cache for ${url}: ${e}`); } 151 | 152 | const favIconKey = `favicon:${cleanedUrl.split("/")[0]}`; 153 | try { 154 | const cache = await browser.storage.local.get(favIconKey); 155 | if (cache[favIconKey] && cache[favIconKey].favicon) { 156 | imgElement.src = cache[favIconKey].favicon; 157 | return; 158 | } 159 | } catch(e) { console.debug(`error cache for ${url}: ${e}`); } 160 | 161 | if(favIconUrl && favIconUrl.startsWith('http')) { 162 | try { 163 | const res = await fetch(favIconUrl, { method: "GET", }); 164 | if (res.ok) { 165 | imgElement.src = favIconUrl; 166 | return; 167 | } 168 | } catch(e) { console.debug(`error getting favicon ${favIconUrl}`); } 169 | } 170 | 171 | imgElement.src = defaultFavIconUrl; 172 | await browser.storage.local.set({ [favIconKey]: { favicon: defaultFavIconUrl } }); 173 | } 174 | 175 | -------------------------------------------------------------------------------- /conex-helper.js: -------------------------------------------------------------------------------- 1 | // alias for document.querySelectorAll 2 | const $ = function(s, parent){ return (parent || document).querySelectorAll(s); }; 3 | 4 | // alias for document.querySelector 5 | const $1 = function(s, parent){ return (parent || document).querySelector(s); }; 6 | 7 | const cleanUrl = function(url) { 8 | return url.replace('http://','').replace('https://','').toLowerCase(); 9 | }; 10 | 11 | var settings = {}; 12 | 13 | function _refreshSettings() { 14 | return new Promise((resolve, reject) => { 15 | browser.storage.local.get([ 16 | 'conex/settings/create-thumbnail', 17 | 'conex/settings/experimental-features', 18 | 'conex/settings/hide-tabs', 19 | 'conex/settings/search-bookmarks', 20 | 'conex/settings/search-history', 21 | 'conex/settings/settings-version', 22 | 'conex/settings/show-container-selector', 23 | 'conex/settings/show-favicons', 24 | 'conex/settings/close-reopened-tabs', 25 | ]).then(localSettings => { 26 | for (const key in localSettings) { 27 | // conex/settings/create-thumbnail -> create-thumbnail 28 | const id = key.split('/')[key.split('/').length - 1]; 29 | settings[id] = localSettings[key]; 30 | } 31 | console.info('settings: ', settings); 32 | resolve(); 33 | }, e => console.error(e)); 34 | }); 35 | } 36 | 37 | let readSettings = _refreshSettings(); 38 | -------------------------------------------------------------------------------- /conex-options-ui.css: -------------------------------------------------------------------------------- 1 | /* remove body when https://bugzilla.mozilla.org/show_bug.cgi?id=1382953 is fixed */ 2 | body { 3 | font-family: Helvetica, Arial, sans-serif; 4 | padding: 5em; 5 | } 6 | 7 | p, h1, label { 8 | -moz-user-select: text; 9 | } 10 | 11 | #missing-tab-container-support { 12 | display: none; 13 | } 14 | 15 | #missing-tab-container-support h1 { 16 | color: red; 17 | } 18 | 19 | input[type="file"] { 20 | display: none; 21 | } 22 | 23 | .custom-file-upload { 24 | display: inline; 25 | padding: 6px 12px; 26 | cursor: pointer; 27 | border: solid 1px rgb(229, 229, 232); 28 | } 29 | 30 | li { 31 | list-style: none; 32 | margin-top: 0.7em; 33 | } 34 | 35 | .custom-file-upload:hover, .custom-file-upload:focus { 36 | background-color: rgb(235, 235, 235); 37 | } 38 | 39 | tt { 40 | font-size: large; 41 | background-color:rgb(240,240,240); 42 | } 43 | 44 | .keys.keyboard-shortcut-help { 45 | font-size: smaller; 46 | color: gray; 47 | text-align: center; 48 | margin-right: 0px; 49 | } 50 | 51 | input.keyboard-shortcut { 52 | font-family: monospace; 53 | cursor: pointer; 54 | font-size: large; 55 | border: 2px solid black; 56 | padding: 4px; 57 | background-color: rgb(240,240,240); 58 | } 59 | 60 | input.keyboard-shortcut:focus { 61 | background-color: lightpink; 62 | } 63 | 64 | .keys { 65 | width: 12em; 66 | display: inline-block; 67 | margin-right: 1em; 68 | } 69 | 70 | 71 | h2 { 72 | border-top: 1px solid rgb(232, 232, 234); 73 | padding-top: 1em; 74 | width: 100%; 75 | margin-top: 2em; 76 | font-weight: 100; 77 | } 78 | 79 | h3 { 80 | font-weight: 100; 81 | text-decoration: underline; 82 | } 83 | 84 | .option-toggle { 85 | position: absolute; 86 | margin-left: -9999px; 87 | visibility: hidden; 88 | } 89 | 90 | .option-toggle+label { 91 | display: inline-block; 92 | position: relative; 93 | cursor: pointer; 94 | outline: none; 95 | user-select: none; 96 | } 97 | 98 | input.option-toggle-round+label { 99 | padding: 2px; 100 | margin: 0.5em 0 0 0; 101 | width: 2em; 102 | height: 1em; 103 | background-color: #dddddd; 104 | border-radius: 1em; 105 | } 106 | 107 | input.option-toggle-round+label:before, input.option-toggle-round+label:after { 108 | display: block; 109 | position: absolute; 110 | top: 1px; 111 | left: 1px; 112 | bottom: 1px; 113 | content: ""; 114 | } 115 | 116 | input.option-toggle-round+label:before { 117 | right: 1px; 118 | background-color: #f1f1f1; 119 | border-radius: 1em; 120 | transition: background 0.4s; 121 | } 122 | 123 | input.option-toggle-round+label:after { 124 | width: 1em; 125 | /* change to 0.65em when moving settings back to about:addons page ... currently blocked by https://bugzilla.mozilla.org/show_bug.cgi?id=1382953 */ 126 | background-color: #fff; 127 | border-radius: 100%; 128 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3); 129 | transition: margin 0.4s; 130 | } 131 | 132 | input.option-toggle-round:checked+label:before { 133 | background-color: #8ce196; 134 | } 135 | 136 | input.option-toggle-round:checked+label:after { 137 | margin-left: 1.15em; 138 | } 139 | 140 | .checkbox-label { 141 | padding-left: 1em; 142 | } 143 | 144 | em { 145 | font-weight: bold; 146 | } 147 | 148 | #moving-tabs-explanation { 149 | margin-top: 30em; 150 | } 151 | -------------------------------------------------------------------------------- /conex-options-ui.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Conex settings 4 | 5 | 6 | 7 | 8 | 9 |
10 |

Options

11 |
12 | 13 | 14 | only show tabs of the current container 15 |
16 | 17 |
18 | 19 | 20 | create tab thumbnails (careful: this can cause performance issues) 21 |
22 | 23 |
24 | 25 | 26 | show favicons in Conex popup (careful: this can cause performance issues) 27 |
28 | 29 |
30 | 31 | 32 | include bookmarks in search 33 |
34 | 35 |
36 | 37 | 38 | include history in search 39 |
40 | 41 |
42 | 43 | 44 | ask in which container to open external links instead of always using the current container 45 |
46 | 47 |
48 | 49 | 50 | (experimental) close tabs that are re-opened in another container 51 |
52 |
53 |
54 |

Keyboard shortcuts

55 | 56 | 57 |
58 |

Browser context

59 |
    60 |
  • ↓ click to edit shortcut ↓
  • 61 |
  • 62 | 63 | 64 | 65 | 66 | 67 | open Conex popup 68 |
  • 69 |
  • 70 | 71 | 72 | 73 | 74 | 75 | create new container 76 |
  • 77 |
  • Ctrl-T / CMD-Topen new tab in current container
  • 78 |
79 | 80 |

Within the Conex popup

81 |
    82 |
  • / Tabjump to next container or tab in the popup list
  • 83 |
  • / Shift+Tabjump to previous container or tab in the popup list
  • 84 |
  • Enteron a container: activate the last accessed tab of the selected container
  • 85 |
  • on a tab: activate selected tab
  • 86 |
  • Backspaceon a tab: close the selected tab
  • 87 |
  • on a container: delete selected container
  • 88 |
  • Backspace+Shifton a container: delete selected container without confirmation dialog
  • 89 |
  • / / Ctrl+Enteron a container: expand / unexpand tab list of the selected container
  • 90 |
  • Ctrl+Shift+Enter / Ctrl+ +on a container: open a new tab in the selected container and activate this tab
  • 91 |
  • in the search box: open new container dialog
  • 92 |
93 |
94 |
95 |

adding, deleting and renaming containers

96 |

Please go to preferences / containers (url: about:preferences#containers) for container management for now

97 |
98 |
99 |

Import TabGroups backup file

100 |

Tabgroups, windows and all tabs will be recreated, apart from urls starting with 'about://' due to technical limitations. All tabs and windows will be opened rather quickly, so expect your CPU fan to be noisy for a while

101 | 105 |
106 | 107 | 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /conex-options-ui.js: -------------------------------------------------------------------------------- 1 | const filePicker = $1('#file-picker'); 2 | const bg = browser.extension.getBackgroundPage(); 3 | 4 | filePicker.addEventListener('change', picker => { 5 | const file = picker.target.files[0]; 6 | 7 | const reader = new FileReader(); 8 | reader.onload = function (r) { 9 | try { 10 | const json = JSON.parse(r.target.result); 11 | const tabContainers = []; 12 | 13 | const windows = []; 14 | for (const w of json.windows) { 15 | const windowTabContainers = {}; 16 | if (w.extData && w.extData['tabview-group']) { 17 | const windowTabContainersJSON = JSON.parse(w.extData['tabview-group']); 18 | 19 | for (const key in windowTabContainersJSON) { 20 | if (windowTabContainersJSON[key].title) { 21 | windowTabContainers[key] = windowTabContainersJSON[key].title; 22 | tabContainers.push(windowTabContainers[key]); 23 | } 24 | } 25 | } 26 | 27 | const tabs = []; 28 | for (const tab of w.tabs) { 29 | if (tab.extData && tab.extData['tabview-tab']) { 30 | const extData = JSON.parse(tab.extData['tabview-tab']); 31 | if (extData && extData.groupID && windowTabContainers[Number(extData.groupID)]) { 32 | tabs.push({ url: tab.entries[tab.entries.length - 1].url, container: windowTabContainers[Number(extData.groupID)] }); 33 | } else { 34 | tabs.push({ url: tab.entries[tab.entries.length - 1].url, container: null }); 35 | } 36 | } else { 37 | tabs.push({ url: tab.entries[tab.entries.length - 1].url, container: null }); 38 | } 39 | } 40 | windows.push(tabs); 41 | } 42 | 43 | bg.restoreTabContainersBackup(tabContainers, windows); 44 | 45 | } catch (e) { console.error(e); } 46 | }; 47 | reader.readAsText(file); 48 | 49 | }); 50 | 51 | async function fillShortcutFields(field) { 52 | const commands = await browser.commands.getAll(); 53 | for(command of commands) { 54 | if(field == undefined || field == command.name) { 55 | $1('#' + command.name).value = command.shortcut; 56 | } 57 | } 58 | } 59 | 60 | async function setupShortcutListeners() { 61 | const isMac = /^Mac/i.test(navigator.platform); 62 | fillShortcutFields(); 63 | 64 | for (input of $('.keyboard-shortcut')) { 65 | input.addEventListener('focus', e => { 66 | e.target.value = ""; 67 | e.target.placeholder = "type shortcut"; 68 | }); 69 | 70 | input.addEventListener('blur', e => { 71 | fillShortcutFields(e.target.id); 72 | }); 73 | 74 | input.addEventListener('keypress', e => { 75 | if(typeof browser.commands.update != 'function') { 76 | alert('shortcut remapping is only available in Firefox >= v60'); 77 | return; 78 | } 79 | const normalizedKey = normalizeKey(e.code); 80 | if((e.ctrlKey || e.altKey || e.metaKey) && normalizedKey) { 81 | const shortcutParts = [e.ctrlKey ? (isMac ? "MacCtrl" : "Ctrl") : (e.altKey ? "Alt" : "Command")]; 82 | if (e.shiftKey) { 83 | shortcutParts.push("Shift"); 84 | } 85 | 86 | shortcutParts.push(normalizedKey); 87 | const shortcut = shortcutParts.join("+"); 88 | 89 | for(const e of $('.keyboard-shortcut')) { 90 | if(shortcut == e.value) { 91 | alert('Key combinations must differ'); 92 | e.target.blur(); 93 | return; 94 | } 95 | } 96 | 97 | e.target.value = shortcut; 98 | browser.commands.update({ 99 | name: e.target.id, 100 | shortcut: shortcut 101 | }); 102 | console.debug(`mapping ${e.target.id} to ${shortcut}`); 103 | e.target.blur(); 104 | } else { 105 | alert(` 106 | Key combinations must consist of two or three keys: 107 | 108 | - modifier (mandatory, except for function keys). This can be any of: "Ctrl", "Alt", "Command", "MacCtrl". 109 | - secondary modifier (optional). If supplied, this must be "Shift". 110 | - key (mandatory). This can be any one of: 111 | the letters A-Z 112 | the numbers 0-9 113 | the function keys F1-F12 114 | Comma, Period, Home, End, PageUp, PageDown, Space, Insert, Delete, Up, Down, Left, Right 115 | 116 | NOTE: if a key-combination triggers a different action, you will not create a new Shortcut -- but 117 | re-assigning does work (try for example Alt+S). 118 | 119 | `); 120 | e.target.blur(); 121 | } 122 | }); 123 | } 124 | } 125 | 126 | // from: https://github.com/piroor/webextensions-lib-shortcut-customize-ui/blob/master/ShortcutCustomizeUI.js 127 | function normalizeKey(value) { 128 | const aKey = value.trim().replace(/^Digit/,"").replace(/^Key/,"").toLowerCase(); 129 | const normalizedKey = aKey.replace(/\s+/g, ''); 130 | if (/^[a-z0-9]$/i.test(normalizedKey) || 131 | /^F([1-9]|1[0-2])$/i.test(normalizedKey)) 132 | return aKey.toUpperCase(); 133 | 134 | switch (normalizedKey) { 135 | case 'comma': 136 | return 'Comma'; 137 | case 'period': 138 | return 'Period'; 139 | case 'home': 140 | return 'Home'; 141 | case 'end': 142 | return 'End'; 143 | case 'pageup': 144 | return 'PageUp'; 145 | case 'pagedown': 146 | return 'PageDown'; 147 | case 'space': 148 | return 'Space'; 149 | case 'del': 150 | case 'delete': 151 | return 'Delete'; 152 | case 'up': 153 | return 'Up'; 154 | case 'down': 155 | return 'Down'; 156 | case 'left': 157 | return 'Left'; 158 | case 'right': 159 | return 'right'; 160 | case 'next': 161 | case 'medianexttrack': 162 | return 'MediaNextTrack'; 163 | case 'play': 164 | case 'pause': 165 | case 'mediaplaypause': 166 | return 'MediaPlayPause'; 167 | case 'prev': 168 | case 'previous': 169 | case 'mediaprevtrack': 170 | return 'MediaPrevTrack'; 171 | case 'stop': 172 | case 'mediastop': 173 | return 'MediaStop'; 174 | 175 | default: 176 | for (let map of [keyNameMapLocales[browser.i18n.getUILanguage()] || 177 | keyNameMapLocales[browser.i18n.getUILanguage().replace(/[-_].+$/, '')] || 178 | {}, keyNameMapLocales.global]) { 179 | for (let key of Object.keys(map)) { 180 | if (Array.isArray(map[key])) { 181 | if (map[key].some(aLocalizedKey => aLocalizedKey.toLowerCase() == aKey)) 182 | return key; 183 | } 184 | else { 185 | if (map[key] && 186 | map[key].toLowerCase() == aKey) 187 | return key; 188 | } 189 | } 190 | } 191 | break; 192 | } 193 | return ''; 194 | } 195 | 196 | const keyNameMapLocales = { 197 | global: { 198 | Comma: [','], 199 | Period: ['.'], 200 | Space: [' '], 201 | Up: ['↑'], 202 | Down: ['↓'], 203 | Left: ['←', '<=', '<-'], 204 | Right: ['→', '=>', '->'], 205 | }, 206 | // define tables with https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/i18n/LanguageCode 207 | ja: { 208 | // key: valid key name listed at https://developer.mozilla.org/en-US/Add-ons/WebExtensions/manifest.json/commands#Shortcut_values 209 | // value: array of localized key names 210 | Up: ['上'], 211 | Down: ['下'], 212 | Left: ['左'], 213 | Right: ['右'], 214 | // you can localize modifier keys also. 215 | // Alt: ['オルト'], 216 | // Ctrl: ['コントロール'], 217 | // MacCtrl: ['コントロール'], // for macOS 218 | // Command: ['コマンド`], // for macOS 219 | // Shift: ['シフト`], 220 | }, 221 | ru: { 222 | // key: valid key name listed at https://developer.mozilla.org/en-US/Add-ons/WebExtensions/manifest.json/commands#Shortcut_values 223 | // value: array of localized key names 224 | Up: ['Вверх'], 225 | Down: ['Вниз'], 226 | Left: ['Влево'], 227 | Right: ['Вправо'], 228 | Comma: ['Запятая'], 229 | Period: ['Точка'], 230 | Space: ['Пробел'], 231 | MediaNextTrack: ['Следующий трек'], 232 | MediaPrevTrack: ['Предыдущий трек'], 233 | MediaPlayPause: ['Пауза проигрывания'], 234 | MediaStop: ['Остановка проигрывания'] 235 | }, 236 | // de: {...}, 237 | // fr: {...}, 238 | } 239 | 240 | setupShortcutListeners(); 241 | 242 | 243 | browser.contextualIdentities.query({}).then(identities => { 244 | if (!identities) { 245 | document.querySelector('#missing-tab-container-support').style.display = 'block'; 246 | } 247 | }, e => console.error(e)); 248 | 249 | var handlePermission = function(setting, value) { 250 | return new Promise((resolve, reject) => { 251 | const mapping = { 252 | 'search-bookmarks': {permissions: ['bookmarks']}, 253 | 'search-history': {permissions: ['history']}, 254 | 'hide-tabs': {permissions: ['tabHide']}, 255 | /* 'create-thumbnail': {origins: ['']}, does not work correctly for optional permissions :( */ 256 | }; 257 | 258 | const permissions = mapping[setting]; 259 | if (permissions) { 260 | if (value) { 261 | permissionQueryOpen = true; 262 | browser.permissions.request(permissions).then(success => { 263 | browser.permissions.getAll().then(permissions => console.debug('current conex permissions:', permissions.permissions, 'origins:', permissions.origins)); 264 | resolve(success); 265 | }).catch(e => { 266 | console.error(`error requesting permission ${setting}`); 267 | reject(e); 268 | }); 269 | } else { 270 | browser.permissions.remove(permissions).then(success => { 271 | browser.permissions.getAll().then(permissions => console.debug('current conex permissions:', permissions.permissions, 'origins:', permissions.origins)); 272 | resolve(success); 273 | }).catch(e => { 274 | console.error(`error removing permission ${setting}`); 275 | reject(e); 276 | }); 277 | } 278 | } else { 279 | resolve(true); 280 | } 281 | }); 282 | } 283 | 284 | readSettings.then(_ => { 285 | for (const key in settings) { 286 | const checkbox = $1('#' + key); 287 | if(settings[key] && checkbox) { 288 | checkbox.checked = 'checked'; 289 | } 290 | } 291 | }); 292 | 293 | let permissionQueryOpen = false; 294 | for(const element of $('input[type=checkbox]')) { 295 | element.addEventListener('click', event => { 296 | const settingId = 'conex/settings/' + event.target.id; 297 | const value = event.target.checked; 298 | if(permissionQueryOpen) { 299 | event.target.checked = !event.target.checked; 300 | return; 301 | } 302 | 303 | 304 | const handlePermissionResult = function(success) { 305 | permissionQueryOpen = false; 306 | if (success) { 307 | console.info(`setting ${settingId} to ${value}`); 308 | browser.storage.local.set({ [settingId]: value }).catch(e => { 309 | console.error(`error setting ${settingId} to ${value}: ${e}`) 310 | }); 311 | bg.refreshSettings(); 312 | } else { 313 | event.target.checked = !event.target.checked; 314 | } 315 | } 316 | 317 | 318 | if (event.target.id == 'hide-tabs') { 319 | if (value == true) { 320 | handlePermission(event.target.id, value).then(success => { 321 | if(success) { 322 | browser.tabs.query({ active: true, windowId: browser.windows.WINDOW_ID_CURRENT }).then(tabs => { 323 | if(tabs.length > 0) { 324 | const activeTab = tabs[0]; 325 | bg.showCurrentContainerTabsOnly(activeTab.id); 326 | } else { 327 | console.error("did not find any active tab in window with id: ", browser.windows.WINDOW_ID_CURRENT); 328 | } 329 | }); 330 | } 331 | handlePermissionResult(success); 332 | }).catch(e => { 333 | permissionQueryOpen = false; 334 | consol.error('error handling permissions:', e); 335 | }); 336 | } else { 337 | browser.tabs.query({}).then(tabs => { 338 | browser.tabs.show(tabs.map(t => t.id)).then(_ => { 339 | handlePermission(event.target.id, value).then(success => { 340 | handlePermissionResult(success); 341 | }).catch(e => { 342 | permissionQueryOpen = false; 343 | consol.error('error handling permissions:', e); 344 | }); 345 | }); 346 | }); 347 | } 348 | } else { 349 | handlePermission(event.target.id, value).then(success => { 350 | handlePermissionResult(success); 351 | }); 352 | } 353 | }); 354 | } 355 | -------------------------------------------------------------------------------- /conex.css: -------------------------------------------------------------------------------- 1 | body, ul { 2 | color: black; 3 | margin: 0; 4 | padding: 0; 5 | font-family: Sans-Serif; 6 | background-color: white; 7 | overflow-x: hidden 8 | } 9 | 10 | #container-selector, #container-selector #main { 11 | display: flex; 12 | flex-direction: column; 13 | align-items: center; 14 | } 15 | 16 | #bookmarks li.tab span.icon, 17 | #bookmarks span.toolbar, 18 | #bookmarks span.tabs-count, 19 | #container-selector span.icon, 20 | #container-selector span.toolbar, 21 | #container-selector span.tabs-count, 22 | #history li.tab span.icon, 23 | #history span.toolbar, 24 | #history span.tabs-count, 25 | .private span.toolbar { 26 | display: none !important; 27 | } 28 | 29 | #bookmarks .tab h2, #bookmarks .tab ul li, #history .tab h2, #history .tab ul li { 30 | width: 400px; 31 | margin: 0 0 0 3em; 32 | height: 2.5em; 33 | } 34 | 35 | #bookmarks .tab h2, #history .tab h2 { 36 | margin: 1em 0 0 0; 37 | } 38 | 39 | li.section .audible-true, #browseraction span.bookmark-marker, span.close-button { 40 | float: right; 41 | display: inherit; 42 | } 43 | 44 | .audible-undefined, .audible-false, li.tab:hover .audible-true { 45 | display: none; 46 | } 47 | 48 | span.close-button { 49 | padding: 3px; 50 | font-size: large; 51 | border-radius: 5%; 52 | display: none; 53 | } 54 | 55 | span.bookmark-marker { 56 | opacity: 0; 57 | } 58 | 59 | li.is-bookmark span.bookmark-marker { 60 | opacity: 1; 61 | } 62 | 63 | li.tab:hover span.close-button, li.tab:focus span.close-button { 64 | background-color: rgb(208, 208, 208); 65 | display: inherit; 66 | opacity: 0.7; 67 | } 68 | 69 | ul li { 70 | border-left: solid 5px rgb(235, 235, 236); 71 | } 72 | 73 | select.blue, select option.blue { 74 | color: rgb(55, 173, 255); 75 | } 76 | 77 | ul.blue li { 78 | border-left: solid 5px rgb(55, 173, 255); 79 | } 80 | 81 | select.turquoise, select option.turquoise { 82 | color: rgb(0, 199, 154); 83 | } 84 | 85 | ul.turquoise li { 86 | border-left: solid 5px rgb(0, 199, 154); 87 | } 88 | 89 | select.green, select option.green { 90 | color: rgb(81, 205, 0); 91 | } 92 | 93 | ul.green li { 94 | border-left: solid 5px rgb(81, 205, 0); 95 | } 96 | 97 | select.yellow, select option.yellow { 98 | color: rgb(255, 203, 0); 99 | } 100 | 101 | ul.yellow li { 102 | border-left: solid 5px rgb(255, 203, 0); 103 | } 104 | 105 | select.orange, select option.orange { 106 | color: rgb(255, 159, 0); 107 | } 108 | 109 | ul.orange li { 110 | border-left: solid 5px rgb(255, 159, 0); 111 | } 112 | 113 | .new-container #new-container { 114 | display: flex; 115 | } 116 | 117 | #new-container { 118 | margin-top: 10px; 119 | width: 495px; 120 | display: none; 121 | justify-content: space-around; 122 | align-items: baseline; 123 | } 124 | 125 | #new-container-name, select, #add-container-button { 126 | font-size: x-large; 127 | } 128 | 129 | select { 130 | text-align: center; 131 | height: 2em; 132 | } 133 | 134 | input[type=submit] { 135 | height: 3em; 136 | align-self: center; 137 | display:flex; 138 | } 139 | 140 | select.red, select option.red { 141 | color: rgb(255, 97, 61); 142 | } 143 | 144 | ul.red li { 145 | border-left: solid 5px rgb(255, 97, 61); 146 | } 147 | 148 | select.pink, select option.pink { 149 | color: rgb(255, 75, 218); 150 | } 151 | 152 | ul.pink li { 153 | border-left: solid 5px rgb(255, 75, 218); 154 | } 155 | 156 | select.purple, select option.purple { 157 | color: rgb(175, 81, 245); 158 | } 159 | 160 | ul.purple li { 161 | border-left: solid 5px rgb(175, 81, 245); 162 | } 163 | 164 | ul.default li { 165 | border-left: solid 5px rgb(0, 0, 0); 166 | } 167 | 168 | ul.private li { 169 | border-left: solid 5px rgb(141, 32, 174); 170 | } 171 | 172 | ul li.tab { 173 | border-left-width: 1px; 174 | } 175 | 176 | .icon-history { 177 | min-width: 1em; 178 | min-height: 1em; 179 | margin: 0 0 0 1em; 180 | display: inline-block; 181 | background-image: url('icons/clock.svg'); 182 | float: left; 183 | } 184 | 185 | .arrow-right, .arrow-down { 186 | width: 0; 187 | height: 0; 188 | display: inline-block; 189 | margin-right: 0.5em; 190 | margin: 0.5em; 191 | } 192 | 193 | .arrow-right { 194 | border-bottom: 0.5em solid transparent; 195 | border-left: 0.5em solid black; 196 | border-top: 0.5em solid transparent; 197 | } 198 | 199 | .arrow-down { 200 | border-left: 0.5em solid transparent; 201 | border-right: 0.5em solid transparent; 202 | border-top: 0.5em solid black; 203 | } 204 | 205 | body.new-container #search-form { 206 | display: none; 207 | } 208 | 209 | #search-form { 210 | width: 490px; 211 | margin: 10px 0px 0px 10px; 212 | background-color: rgb(252, 252, 252); 213 | padding-top: 10px; 214 | display: flex; 215 | justify-content: space-around; 216 | } 217 | 218 | #search-form #search { 219 | font-size: large; 220 | font-weight: lighter; 221 | width: 433px; 222 | border: 1px solid rgb(252, 252, 252); 223 | } 224 | 225 | #search-form a { 226 | width: 2em; 227 | font-size: x-large; 228 | color: #000; 229 | text-decoration: none; 230 | display: flex; 231 | justify-content: center; 232 | } 233 | 234 | #search-form a:hover { 235 | background-color: rgb(220, 220, 222); 236 | } 237 | 238 | li { 239 | font-size: smaller; 240 | font-weight: lighter; 241 | list-style-type: none; 242 | width: 500px; 243 | } 244 | 245 | li.section { 246 | background-color: rgb(235, 235, 236); 247 | border-bottom: 1px solid rgb(208, 208, 208); 248 | height: 3.5em; 249 | display: flex; 250 | flex-direction: row; 251 | flex-wrap: nowrap; 252 | align-items: center; 253 | align-content: center; 254 | width: 495px; 255 | } 256 | 257 | li.section>span { 258 | height: 100%; 259 | display: flex; 260 | align-items: center; 261 | justify-content: center; 262 | } 263 | 264 | li.section>span.icon { 265 | flex-basis: 2em; 266 | align-self: flex-start; 267 | } 268 | 269 | li.section>span.name { 270 | align-self: auto; 271 | justify-content: flex-start; 272 | flex-grow: 2; 273 | padding-left: 0.5em; 274 | } 275 | 276 | li.section>span.tabs-count { 277 | flex-basis: 5em; 278 | } 279 | 280 | li.section>span.toolbar { 281 | flex-basis: 1em; 282 | font-size: xx-large; 283 | font-weight: 100; 284 | } 285 | 286 | li.section:hover>span.tabs-count, 287 | li.section>span.toolbar, 288 | li.section.confirming:hover>span.toolbar { 289 | display: none; 290 | } 291 | 292 | li.section>span.tabs-count, li.section:hover>span.toolbar { 293 | display: inherit; 294 | } 295 | 296 | li.section>span:hover, li.section:focus { 297 | background-color: rgb(220, 220, 222); 298 | cursor: pointer; 299 | } 300 | 301 | li.tab { 302 | background-color: rgb(252, 252, 252); 303 | border-bottom: 1px solid rgb(237, 237, 237); 304 | padding: 10px; 305 | width: 480px; 306 | } 307 | 308 | li.section.confirming span, li.section img { 309 | display:none; 310 | } 311 | 312 | .confirming div.delete-container-confirmation { 313 | height: 100%; 314 | width: 100%; 315 | display: flex; 316 | } 317 | 318 | li.section.confirming .delete-container-confirmation span { 319 | display: inline-block; 320 | padding: 10px; 321 | } 322 | 323 | .delete-container-confirmation { 324 | display:none; 325 | } 326 | 327 | .delete-container-confirmation b { 328 | font-weight: 900; 329 | } 330 | 331 | .confirming .delete-container-confirmation .no, .confirming .delete-container-confirmation .yes { 332 | width: 2em; 333 | text-align: center; 334 | cursor: pointer; 335 | } 336 | 337 | .confirming .delete-container-confirmation .yes { 338 | background-color: rgba(81, 205, 0, 0.5); 339 | } 340 | 341 | .confirming .delete-container-confirmation .yes:hover { 342 | background-color: rgba(81, 205, 0, 1); 343 | } 344 | 345 | 346 | .confirming .delete-container-confirmation .no { 347 | background-color: rgb(255, 97, 61, 0.7); 348 | } 349 | 350 | .confirming .delete-container-confirmation .no:hover { 351 | background-color: rgb(255, 97, 61, 1); 352 | } 353 | 354 | li.tab:focus, li.tab:hover { 355 | background-color: rgb(232, 232, 232); 356 | cursor: pointer; 357 | } 358 | 359 | li.tab div { 360 | display: flex; 361 | align-items: center; 362 | } 363 | 364 | li.tab div.image { 365 | background-repeat: space !important; 366 | background-size: cover !important; 367 | height: 42px; 368 | text-align: center; 369 | vertical-align: middle; 370 | width: 56px; 371 | margin-right: 10px; 372 | } 373 | 374 | li.tab div.image img { 375 | width: 20px; 376 | } 377 | 378 | li.tab img.no-thumbnail { 379 | width: 20px; 380 | margin-right: 10px; 381 | } 382 | 383 | li.tab img.no-thumbnail+div.text { 384 | max-width: 436px; 385 | } 386 | 387 | li.tab img.no-thumbnail+div.text div { 388 | width: 416px; 389 | } 390 | 391 | li.tab img+div.text, li.tab span+div.text { 392 | max-width: 486px; 393 | height: 2.5em; 394 | } 395 | 396 | li.tab span+div.text div { 397 | width: 466px; 398 | } 399 | 400 | li.tab:hover span+div.text div { 401 | width: 436px; 402 | } 403 | 404 | li.tab div.text { 405 | text-overflow: ellipsis; 406 | white-space: nowrap; 407 | max-width: 400px; 408 | overflow: hidden; 409 | display: block; 410 | } 411 | 412 | li.tab div.text div { 413 | text-overflow: ellipsis; 414 | overflow: hidden; 415 | width: 380px; 416 | display: block !important; 417 | } 418 | 419 | .image { 420 | align-items: end !important; 421 | border: 1px solid rgb(192, 192, 192); 422 | } 423 | 424 | .tab-url { 425 | color: rgb(152, 152, 152); 426 | } 427 | -------------------------------------------------------------------------------- /container-selector.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /example-sessions.tar.bz2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kesselborn/conex/aa3492d2fcd21903ecd716e3f27712cfb252b1ce/example-sessions.tar.bz2 -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kesselborn/conex/aa3492d2fcd21903ecd716e3f27712cfb252b1ce/favicon.ico -------------------------------------------------------------------------------- /icons/blue_dot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /icons/clock.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kesselborn/conex/aa3492d2fcd21903ecd716e3f27712cfb252b1ce/icons/clock.afdesign -------------------------------------------------------------------------------- /icons/clock.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /icons/green_dot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /icons/icon.afdesign: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kesselborn/conex/aa3492d2fcd21903ecd716e3f27712cfb252b1ce/icons/icon.afdesign -------------------------------------------------------------------------------- /icons/icon_19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kesselborn/conex/aa3492d2fcd21903ecd716e3f27712cfb252b1ce/icons/icon_19.png -------------------------------------------------------------------------------- /icons/icon_38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kesselborn/conex/aa3492d2fcd21903ecd716e3f27712cfb252b1ce/icons/icon_38.png -------------------------------------------------------------------------------- /icons/icon_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kesselborn/conex/aa3492d2fcd21903ecd716e3f27712cfb252b1ce/icons/icon_48.png -------------------------------------------------------------------------------- /icons/icon_64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kesselborn/conex/aa3492d2fcd21903ecd716e3f27712cfb252b1ce/icons/icon_64.png -------------------------------------------------------------------------------- /icons/icon_bw_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kesselborn/conex/aa3492d2fcd21903ecd716e3f27712cfb252b1ce/icons/icon_bw_48.png -------------------------------------------------------------------------------- /icons/icon_error_19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kesselborn/conex/aa3492d2fcd21903ecd716e3f27712cfb252b1ce/icons/icon_error_19.png -------------------------------------------------------------------------------- /icons/icon_error_38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kesselborn/conex/aa3492d2fcd21903ecd716e3f27712cfb252b1ce/icons/icon_error_38.png -------------------------------------------------------------------------------- /icons/icon_error_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kesselborn/conex/aa3492d2fcd21903ecd716e3f27712cfb252b1ce/icons/icon_error_48.png -------------------------------------------------------------------------------- /icons/loudspeaker.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | image/svg+xml 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /icons/orange_dot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /icons/pink_dot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /icons/private-browsing.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /icons/purple_dot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /icons/red_dot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /icons/turquoise_dot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /icons/yellow_dot.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 2, 3 | "author": "kesselborn", 4 | "description": "A tabgroup'esque successor for Firefox Quantum (FirefoxBeta and FirefoxNightly only at the moment)", 5 | "homepage_url": "https://github.com/kesselborn/conex", 6 | "name": "Conex (preview)", 7 | "version": "0.9.14", 8 | "browser_action": { 9 | "default_title": "Conex", 10 | "browser_style": false, 11 | "default_popup": "conex-browser-action.html", 12 | "default_icon": { 13 | "48": "icons/icon_48.png" 14 | } 15 | }, 16 | "applications": { 17 | "gecko": { 18 | "strict_min_version": "60.0a1", 19 | "update_url": "https://raw.githubusercontent.com/kesselborn/conex/master/versions.json" 20 | } 21 | }, 22 | "commands": { 23 | "_execute_browser_action": { 24 | "suggested_key": { 25 | "default": "MacCtrl+Space" 26 | } 27 | }, 28 | "new-container": { 29 | "suggested_key": { 30 | "default": "Alt+Shift+N" 31 | } 32 | } 33 | }, 34 | "options_ui": { 35 | "page": "conex-options-ui.html", 36 | "browser_style": false, 37 | "open_in_tab": true 38 | }, 39 | "background": { 40 | "scripts": [ 41 | "conex-helper.js", 42 | "conex-components.js", 43 | "conex-actions-common.js", 44 | "conex-background.js" 45 | ] 46 | }, 47 | "icons": { 48 | "48": "icons/icon_48.png" 49 | }, 50 | "web_accessible_resources": [ 51 | "container-selector.html" 52 | ], 53 | "permissions": [ 54 | "", 55 | "contextualIdentities", 56 | "cookies", 57 | "menus", 58 | "storage", 59 | "tabs", 60 | "webNavigation", 61 | "webRequest", 62 | "webRequestBlocking" 63 | ], 64 | "optional_permissions": [ 65 | "activeTab", 66 | "bookmarks", 67 | "history", 68 | "notifications", 69 | "tabHide" 70 | ] 71 | } 72 | -------------------------------------------------------------------------------- /release: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | # (export VERSION=0.0.15; git reset --hard HEAD^; git tag -d v${VERSION}; git push origin :v${VERSION}; git push -f) 3 | if [ -z "${PROD_ONLY}" ] 4 | then 5 | if git status|grep modified 6 | then 7 | echo "please commit changes first" 8 | exit 1 9 | fi 10 | 11 | test -n "${WEB_EXT_API_KEY:?"please set WEB_EXT_API_KEY"}" 12 | test -n "${WEB_EXT_API_SECRET:?"please set WEB_EXT_API_SECRET"}" 13 | test -n "${GITHUB_TOKEN:?please set GITHUB_TOKEN env var}" 14 | 15 | current_version=$(cat manifest.json |jq -r ".version") 16 | next_patch_version=$(( $(echo ${current_version}|sed -E "s/([0-9]+)\.([0-9]+)\.([0-9]+)/\3/") + 1 )) 17 | next_version=$(echo ${current_version}|sed -E "s/([0-9]+)\.([0-9]+)\.([0-9]+)/\1.\2.${next_patch_version}/") 18 | 19 | read -p "current version is ${current_version} ... next version (${next_version}): " next_version_user 20 | next_version=${next_version_user:-${next_version}} 21 | 22 | # bump version in manifest.json 23 | sed -i "" -e "s/: \"${current_version}\"/: \"${next_version}\"/" manifest.json 24 | 25 | # add new release to versions.json 26 | jq ".addons.\"{cad4f60e-e46e-4d66-8c11-e09194d4df7d}\".updates += [{\"version\":\"${next_version}\", \"update_link\": \"https://github.com/kesselborn/conex/releases/download/v${next_version}/conex-${next_version}-an.fx.xpi\"}]" versions.json > next_versions.json 27 | mv next_versions.json versions.json 28 | 29 | # commit version bumps and create new tag 30 | git commit -m "Bump to version ${next_version}" manifest.json versions.json 31 | git tag v${next_version} 32 | 33 | # create signed addon 34 | #docker run -v ${PWD}:${PWD} -w ${PWD} -e HOME=${PWD} web-ext 35 | web-ext sign --api-key ${WEB_EXT_API_KEY:?"please set WEB_EXT_API_KEY"} \ 36 | --api-secret ${WEB_EXT_API_SECRET:?"please set WEB_EXT_API_SECRET"} \ 37 | --ignore-files example-sessions tags tabgroups-backup.json tabgroups-backup-small.json web-ext-artifacts *.xpi icons/*.afdesign release versions.json || true 38 | 39 | if [ -n "${NO_RELEASE}" ] 40 | then 41 | exit 0 42 | fi 43 | 44 | { git push --tags && git push; } || true 45 | 46 | ################ github release upload 47 | AUTH="Authorization: token ${GITHUB_TOKEN:?please set GITHUB_TOKEN env var}" 48 | VERSION=v${next_version} 49 | LOCAL_ASSET=web-ext-artifacts/conex-${next_version}-an+fx.xpi 50 | 51 | repo=$(git remote get-url origin|cut -d: -f2|sed 's/\.git$//g') 52 | 53 | release_notes_file=$(mktemp /tmp/release_notes.XXXXXX) 54 | echo -e "release notes for github:\n$(git log --format=%B -n 1 HEAD^1)\n\n$(git log --format=%B -n 1 HEAD)" > ${release_notes_file} 55 | vi ${release_notes_file} || true 56 | (set -x; cat release_notes_file|pbcopy) 57 | release_notes=$(cat ${release_notes_file} | while read l; do echo -n "$l\\n"; done) 58 | release_body="{\"tag_name\":\"${VERSION}\",\"name\":\"${VERSION}\",\"body\":\"${release_notes}\",\"draft\":false,\"prerelease\":false}" 59 | rm -f ${release_notes_file} 60 | 61 | response=$(set -x; curl -ifL -H"Accept: application/vnd.github.v3+json" -H"${AUTH}" -XPOST -d "${release_body}" https://api.github.com/repos/${repo}/releases) \ 62 | || { echo "Error: does the release already exist? Check https://github.com/${repo}/releases/tag/${VERSION}"; exit 1; } 63 | 64 | upload_url=$(echo ${response}|sed -E 's/.*"upload_url": "([^"]+)".*/\1/g'|cut -d{ -f1) 65 | 66 | echo "Uploading asset... $LOCAL_ASSET" >&2 67 | 68 | curl -L# --data-binary @"${LOCAL_ASSET}" \ 69 | -H "Content-Type: application/x-xpinstall" \ 70 | -H"Accept: application/vnd.github.v3+json" -H"${AUTH}" \ 71 | -XPOST ${upload_url}?name=$(basename $LOCAL_ASSET) 72 | 73 | cat< next_manifest.json 91 | mv next_manifest.json manifest.json 92 | 93 | #docker run -v ${PWD}:${PWD} -w ${PWD} -e HOME=${PWD} web-ext 94 | web-ext sign --id '{ec9d70ea-0229-49c0-bbf7-0df9bbccde35}' --api-key ${WEB_EXT_API_KEY:?'please set WEB_EXT_API_KEY'} --api-secret ${WEB_EXT_API_SECRET:?'please set WEB_EXT_API_SECRET'} --ignore-files example-sessions tags tabgroups-backup.json tabgroups-backup-small.json web-ext-artifacts *.xpi icons/*.afdesign release versions.json || true 95 | 96 | git checkout manifest.json 97 | 98 | cat<