├── .eslintignore ├── .eslintrc.js ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md └── workflows │ └── build-and-test.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── config ├── paths.js ├── webpack.common.js └── webpack.config.js ├── images ├── 1-main-window.png ├── 2-new-workspace-dialog.png ├── 3-new-workspace-dialog-name.png ├── 4-settings-import-export-window.png ├── Firefox_addon_store_badge_EN.png ├── Google_Play_Store_badge_EN.svg ├── extension-demo.gif ├── icon.psd ├── small-promo-tile.png ├── v2-1-main-window.png ├── v2-2-new-workspace-dialog.png ├── v2-3-new-workspace-dialog-name.png ├── v2-4-settings-import-export-window.png ├── v2ff-1-main-window.png ├── v2ff-2-new-workspace-dialog.png ├── v2ff-3-new-workspace-dialog-name.png └── v2ff-4-settings-import-export-window.png ├── jest.config.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── public-flavors ├── chrome │ ├── manifest.json │ └── popup.html └── firefox │ ├── manifest.json │ ├── popup-firefox.js │ ├── popup.html │ └── sidebar.html ├── public └── icons │ ├── icon_128.png │ ├── icon_16.png │ ├── icon_32.png │ └── icon_48.png ├── scripts └── sync-version.js ├── src ├── background.ts ├── constants │ ├── constants.ts │ ├── message-responses.ts │ └── messages.ts ├── dialogs │ └── base-dialog.ts ├── globals.ts ├── interfaces │ ├── i-stub.ts │ ├── i-tab-json.ts │ ├── i-workspace-json.ts │ └── messages.ts ├── log-helper.ts ├── messages │ ├── background-message-handlers.ts │ └── popup-message-helper.ts ├── obj │ ├── tab-group-stub.ts │ ├── tab-stub.ts │ └── workspace.ts ├── pages │ ├── page-add-workspace.ts │ └── page-settings.ts ├── popup-actions.ts ├── popup.css ├── popup.js ├── storage-helper.ts ├── storage │ ├── bookmark-storage-helper.ts │ ├── debug-storage-helpet.ts │ └── sync-workspace-storage.ts ├── templates │ ├── dialogAddWorkspaceTemplate.html │ ├── dialogPopupTemplate.html │ ├── dialogSettingsTemplate.html │ └── workspaceElemTemplate.html ├── test │ ├── data │ │ └── sync-storage-data-1.json │ ├── e2e │ │ ├── basic-workflow.test.ts │ │ ├── rename-workflow.test.ts │ │ └── utils │ │ │ ├── e2e-common.ts │ │ │ └── e2e-constants.ts │ ├── jest │ │ ├── mock-extension-apis.js │ │ └── setup-jest.js │ └── unit │ │ ├── background-message-handlers.test.js │ │ ├── background.test.js │ │ ├── bookmark-storage-helper.test.js │ │ ├── message-responses.test.js │ │ ├── storage-helper.test.js │ │ ├── storage │ │ └── sync-workspace-storage.test.js │ │ ├── utils.test.js │ │ ├── utils │ │ ├── chunk.test.js │ │ └── workspace-utils.test.js │ │ └── workspace-storage.test.js ├── types │ └── html.d.ts ├── utils.ts ├── utils │ ├── chunk.ts │ ├── debounce.ts │ ├── feature-detect.ts │ ├── prompt.ts │ ├── tab-utils.ts │ └── workspace-utils.ts ├── workspace-entry-logic.ts └── workspace-storage.ts ├── tsconfig.json └── tsconfig.test.json /.eslintignore: -------------------------------------------------------------------------------- 1 | *.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended" 9 | ], 10 | "overrides": [ 11 | ], 12 | "parser": "@typescript-eslint/parser", 13 | "parserOptions": { 14 | "ecmaVersion": "latest", 15 | "sourceType": "module" 16 | }, 17 | "plugins": [ 18 | "@typescript-eslint", 19 | "eslint-plugin-tsdoc" 20 | ], 21 | "rules": { 22 | "tsdoc/syntax": "warn", 23 | // Note: you must disable the base rule as it can report incorrect errors 24 | "no-unused-vars": "off", 25 | "@typescript-eslint/no-unused-vars": "off", 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: Elec0 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Retrieve the logs (Chrome)** 14 | 1. Open the extension 15 | 2. Right click in it and click Inspect 16 | ![Image](https://github.com/user-attachments/assets/427b4ebb-165a-4acd-b920-5b2dfc6066db) 17 | 3. A new window will appear, click on the 'Console' tab 18 | ![Image](https://github.com/user-attachments/assets/860eff82-5fd7-4b09-8e9c-243a671492f7) 19 | 4. On the right side there'll be a dropdown, click it and make sure all the levels are selected 20 | ![Image](https://github.com/user-attachments/assets/321dcd3e-b892-4406-9f2a-f434ae4bb468) 21 | 5. Right click in the window and click 'Save as...', then upload that .log file here 22 | ![Image](https://github.com/user-attachments/assets/0f8aba01-d149-4a4b-a931-bcc4777e9688) 23 | 24 | **To Reproduce** 25 | Steps to reproduce the behavior: 26 | 1. Go to '...' 27 | 2. Click on '....' 28 | 3. Scroll down to '....' 29 | 4. See error 30 | 31 | **Expected behavior** 32 | A clear and concise description of what you expected to happen. 33 | 34 | **Screenshots** 35 | If applicable, add screenshots to help explain your problem. 36 | 37 | **Desktop (please complete the following information):** 38 | - OS: [e.g. iOS] 39 | - Browser [e.g. chrome, safari] 40 | - Version [e.g. 22] 41 | 42 | **Additional context** 43 | Add any other context about the problem here. 44 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build-and-test: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v3 18 | 19 | - name: Set up Node.js 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: '20' 23 | cache: 'npm' 24 | 25 | - name: Install dependencies 26 | run: npm install 27 | 28 | - name: Build the project 29 | run: npm run build 30 | 31 | - name: Run unit tests 32 | run: npm run test-unit -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # production 5 | /build 6 | /releases 7 | 8 | # misc 9 | .DS_Store 10 | 11 | /coverage 12 | 13 | npm-debug.log* 14 | size-plugin.json -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "command": "npm run watch", 9 | "name": "Run npm watch", 10 | "request": "launch", 11 | "type": "node-terminal" 12 | }, 13 | { 14 | "command": "npm run watch-firefox", 15 | "name": "Run npm watch firefox", 16 | "request": "launch", 17 | "type": "node-terminal" 18 | }, 19 | { 20 | "type": "chrome", 21 | "request": "launch", 22 | "name": "Launch Chrome against localhost", 23 | "url": "chrome-extension://feehlkcbifmladjmmpkghfokcngfkkkp/popup.html", 24 | //chrome://extensions/ 25 | "webRoot": "${workspaceFolder}", 26 | // "userDataDir": "${workspaceRoot}.vscode/chrome", 27 | "runtimeArgs": [ 28 | "--disable-infobars --disable-session-crashed-bubble", 29 | "--load-extension=${workspaceFolder}/build/chrome" 30 | ], 31 | }, 32 | { 33 | "type": "firefox", 34 | "request": "launch", 35 | "reAttach": true, 36 | "name": "Launch Firefox with add-on", 37 | "addonPath": "${workspaceFolder}/build/firefox", 38 | "url": "about:debugging#/runtime/this-firefox", 39 | } 40 | ] 41 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "jest.runMode": "on-save", 3 | "jest.jestCommandLine": "npm run test-unit --", 4 | "workbench.colorCustomizations": { 5 | "activityBar.activeBackground": "#23114e", 6 | "activityBar.background": "#23114e", 7 | "activityBar.foreground": "#e7e7e7", 8 | "activityBar.inactiveForeground": "#e7e7e799", 9 | "activityBarBadge.background": "#833b1d", 10 | "activityBarBadge.foreground": "#e7e7e7", 11 | "commandCenter.border": "#e7e7e799", 12 | "sash.hoverBorder": "#23114e", 13 | "statusBar.background": "#100824", 14 | "statusBar.foreground": "#e7e7e7", 15 | "statusBarItem.hoverBackground": "#23114e", 16 | "statusBarItem.remoteBackground": "#100824", 17 | "statusBarItem.remoteForeground": "#e7e7e7", 18 | "titleBar.activeBackground": "#100824", 19 | "titleBar.activeForeground": "#e7e7e7", 20 | "titleBar.inactiveBackground": "#10082499", 21 | "titleBar.inactiveForeground": "#e7e7e799" 22 | }, 23 | "peacock.color": "#100824" 24 | 25 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) <2024>, 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of the copyright holder nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Edge Workspaces 2 | 3 | **Edge Workspaces** is an extension that replicates the Microsoft Edge Workspaces feature. It allows you to save the state of a window, including all open tabs, and reopen it later as a workspace. 4 | 5 | 6 | 7 | 8 | 9 | 10 | ## Key Features 11 | 12 | - **Automatic Saving**: The extension automatically saves your workspace as you work, eliminating the need to manually save open tabs. 13 | - **Import/Export**: Workspaces can be exported to a file and imported later on another device. 14 | - **Workspaces copied as bookmarks**: Workspaces can be saved as bookmarks to allow for easier cross-platform access. 15 | - **Internal Tab Exclusion**: Internal tabs, such as the new tab page, settings, or extensions, are not saved to workspaces. 16 | - **Keep in sync:** Your workspaces stay in sync across computers via Google sync 17 | 18 | ## Usage 19 | 20 | ### Creating a Workspace 21 | 22 | 1. Open the extension popup by clicking the icon in the toolbar (pinning the extension is recommended). 23 | 2. Click the "+" icon to open the new workspace modal. 24 | 3. Select either "New workspace" or "New workspace from window". 25 | 4. Enter a name for the workspace. 26 | 5. Click "OK". 27 | 6. A new browser window will open with the New Tab page. 28 | 7. All tabs in the current window will be saved to the workspace as you work. 29 | 8. Close the window when finished. 30 | 31 | ### Opening a Workspace 32 | 33 | 1. Open the extension popup. 34 | 2. Click on a workspace to open it. 35 | 3. The saved tabs will be opened in a new browser window. 36 | 37 | ### Managing Workspaces 38 | 39 | - Click the trashcan icon to delete a workspace. 40 | - Click the pencil icon to rename a workspace. 41 | 42 | ### Saving Workspaces as Bookmarks 43 | 44 | - Ensure "Save workspaces to bookmarks" option is checked in Settings. 45 | - Workspaces will now be copied to `Other bookmarks -> Edge Workspaces (read-only) -> [Workspace Name]`. 46 | - Note that changes to the bookmarks will **not** be reflected in the workspaces themselves, as they are just a copy. 47 | - When installing a new version, make sure to open old workspaces at least once to allow for them to be saved as bookmarks. 48 | 49 | ### Importing/Exporting Workspaces 50 | 51 | 1. Open the extension popup. 52 | 2. Click the hamburger icon to open the settings window. 53 | 3. Click "Export" to save all workspaces to a file. 54 | 4. Click "Import" to load workspaces from a file. 55 | 56 | ## Images 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | ## Install 66 | ### Chrome Web Store 67 | 68 | 69 | ### Firefox Addon Store 70 | 71 | 72 | ## Contribution 73 | Suggestions and pull requests are welcomed! 74 | 75 | ## Development 76 | 1. Clone the repository 77 | 2. Run `npm install` 78 | 3. Run `npm run build` to build the extension 79 | 4. Load the extension in Chrome by following the manual install instructions 80 | 5. Run `npm run watch` to automatically rebuild the extension when changes are made 81 | 82 | ### Chrome 83 | Using VS Code, there are two tasks available for Chrome: 84 | * `Run npm watch` - Runs `npm run watch` 85 | * `Launch Chrome against localhost` - Launches a new Chrome window with the extension loaded 86 | * You will need to update extension ID in the `url` in `launch.json` to match the ID of the extension loaded in Chrome 87 | 88 | ### Firefox 89 | Using VS Code, there are two tasks available for Firefox: 90 | * `Run npm watch firefox` - Runs `npm run watch-firefox` 91 | * `Launch Firefox with add-on` - Launches a new Firefox window with the extension loaded in debug mode 92 | * Firefox only supports temporary installation of addons in debug mode, so data will not be saved between sessions 93 | 94 | 95 | ## Credits 96 | * Original extension icon made by [Yogi Aprelliyanto](https://www.flaticon.com/authors/yogi-aprelliyanto) from [Flaticon](https://www.flaticon.com/) 97 | * This project was bootstrapped with [Chrome Extension CLI](https://github.com/dutiyesh/chrome-extension-cli) -------------------------------------------------------------------------------- /config/paths.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | 5 | const PATHS = { 6 | src: path.resolve(__dirname, '../src'), 7 | build: path.resolve(__dirname, '../build'), 8 | }; 9 | 10 | module.exports = PATHS; 11 | -------------------------------------------------------------------------------- /config/webpack.common.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const SizePlugin = require('size-plugin'); 4 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 5 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 6 | const webpack = require('webpack') 7 | 8 | const PATHS = require('./paths'); 9 | 10 | // To re-use webpack configuration across templates, 11 | // CLI maintains a common webpack configuration file - `webpack.common.js`. 12 | // Whenever user creates an extension, CLI adds `webpack.common.js` file 13 | // in template's `config` folder 14 | const common = { 15 | output: { 16 | // the build folder to output bundles and assets in. 17 | path: PATHS.build, 18 | // the filename template for entry chunks 19 | filename: '[name].js', 20 | clean: true, 21 | }, 22 | devtool: 'source-map', 23 | stats: { 24 | all: false, 25 | errors: true, 26 | builtAt: true, 27 | }, 28 | resolve: { 29 | extensions: ['.js', '.ts'], 30 | }, 31 | module: { 32 | rules: [ 33 | // Help webpack in understanding CSS files imported in .js files 34 | { 35 | test: /\.css$/, 36 | use: [MiniCssExtractPlugin.loader, 'css-loader'], 37 | }, 38 | // Check for images imported in .js files and 39 | { 40 | test: /\.(png|jpe?g|gif)$/i, 41 | use: [ 42 | { 43 | loader: 'file-loader', 44 | options: { 45 | outputPath: 'images', 46 | name: '[name].[ext]', 47 | }, 48 | }, 49 | ], 50 | exclude: /node_modules/, 51 | }, 52 | { 53 | test: /\.ts$/, 54 | use: { 55 | loader: "ts-loader", 56 | options: { 57 | configFile: "tsconfig.json", 58 | }, 59 | }, 60 | exclude: ["/node_modules/", "/src/test/"] 61 | }, 62 | { 63 | test: /\.html$/, 64 | use: ['html-loader'] 65 | } 66 | ], 67 | }, 68 | plugins: [ 69 | // Print file sizes 70 | new SizePlugin(), 71 | // Copy static assets from `public` folder to `build` folder 72 | new CopyWebpackPlugin({ 73 | patterns: [ 74 | { 75 | from: '**/*', 76 | context: 'public', 77 | }, 78 | ] 79 | }), 80 | // Extract CSS into separate files 81 | new MiniCssExtractPlugin({ 82 | filename: '[name].css', 83 | }), 84 | // fix "process is not defined" error: 85 | new webpack.ProvidePlugin({ 86 | process: 'process/browser', 87 | }), 88 | // Let us use the version from package.json in our code 89 | new webpack.DefinePlugin({ 90 | VERSION: JSON.stringify(require("../package.json").version) 91 | }) 92 | ], 93 | }; 94 | 95 | module.exports = common; 96 | -------------------------------------------------------------------------------- /config/webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { merge } = require('webpack-merge'); 4 | 5 | const common = require('./webpack.common.js'); 6 | const PATHS = require('./paths'); 7 | const webpack = require('webpack'); 8 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 9 | const path = require('path'); 10 | 11 | const targetBrowser = process.env.TARGET_BROWSER || 'chrome'; 12 | 13 | // Merge webpack configuration files 14 | const config = merge(common, { 15 | entry: { 16 | popup: PATHS.src + '/popup.js', 17 | background: PATHS.src + '/background.ts', 18 | }, 19 | output: { 20 | path: path.resolve(__dirname, `../build/${targetBrowser}`), 21 | filename: '[name].js', 22 | }, 23 | plugins: [ 24 | new webpack.DefinePlugin({ 25 | 'process.env.TARGET_BROWSER': JSON.stringify(targetBrowser), 26 | }), 27 | new CopyWebpackPlugin({ 28 | patterns: [ 29 | { 30 | from: path.resolve(__dirname, `../public-flavors/${targetBrowser}`), 31 | to: path.resolve(__dirname, `../build/${targetBrowser}`), 32 | } 33 | ] 34 | }), 35 | ], 36 | }); 37 | 38 | module.exports = config; -------------------------------------------------------------------------------- /images/1-main-window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/images/1-main-window.png -------------------------------------------------------------------------------- /images/2-new-workspace-dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/images/2-new-workspace-dialog.png -------------------------------------------------------------------------------- /images/3-new-workspace-dialog-name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/images/3-new-workspace-dialog-name.png -------------------------------------------------------------------------------- /images/4-settings-import-export-window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/images/4-settings-import-export-window.png -------------------------------------------------------------------------------- /images/Firefox_addon_store_badge_EN.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/images/Firefox_addon_store_badge_EN.png -------------------------------------------------------------------------------- /images/Google_Play_Store_badge_EN.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml 3 | -------------------------------------------------------------------------------- /images/extension-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/images/extension-demo.gif -------------------------------------------------------------------------------- /images/icon.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/images/icon.psd -------------------------------------------------------------------------------- /images/small-promo-tile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/images/small-promo-tile.png -------------------------------------------------------------------------------- /images/v2-1-main-window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/images/v2-1-main-window.png -------------------------------------------------------------------------------- /images/v2-2-new-workspace-dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/images/v2-2-new-workspace-dialog.png -------------------------------------------------------------------------------- /images/v2-3-new-workspace-dialog-name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/images/v2-3-new-workspace-dialog-name.png -------------------------------------------------------------------------------- /images/v2-4-settings-import-export-window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/images/v2-4-settings-import-export-window.png -------------------------------------------------------------------------------- /images/v2ff-1-main-window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/images/v2ff-1-main-window.png -------------------------------------------------------------------------------- /images/v2ff-2-new-workspace-dialog.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/images/v2ff-2-new-workspace-dialog.png -------------------------------------------------------------------------------- /images/v2ff-3-new-workspace-dialog-name.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/images/v2ff-3-new-workspace-dialog-name.png -------------------------------------------------------------------------------- /images/v2ff-4-settings-import-export-window.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/images/v2ff-4-settings-import-export-window.png -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest/presets/js-with-ts-esm', 3 | 4 | // roots: [ 5 | // "/src", 6 | // ], 7 | setupFiles: ['/src/test/jest/mock-extension-apis.js'], 8 | setupFilesAfterEnv: ['/src/test/jest/setup-jest.js'], 9 | // testMatch is handled in package.json 10 | // testMatch: [ 11 | // "/test/unit/*.test.[tj]s" 12 | // ], 13 | 14 | extensionsToTreatAsEsm: ['.ts'], 15 | transform: { 16 | "^.+\\.ts$": ["ts-jest", { tsconfig: "tsconfig.test.json" }] 17 | }, 18 | // Without the following, the tests share mocks between them, for *some* reason 19 | restoreMocks: true, 20 | clearMocks: true, 21 | resetMocks: true, 22 | }; -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "typeAcquisition": {"include": ["chrome"]}, 3 | "exclude": ["node_modules", "build"], 4 | 5 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "edge-workspaces", 3 | "version": "1.2.1", 4 | "description": "Edge workspaces extension", 5 | "private": true, 6 | "scripts": { 7 | "prebuild": "node scripts/sync-version.js", 8 | "watch": "node ./node_modules/webpack/bin/webpack.js --mode=development --watch --config config/webpack.config.js", 9 | "watch-firefox": "cross-env TARGET_BROWSER=firefox npm run watch", 10 | 11 | "build": "npm run build-firefox && npm run build-chrome", 12 | "build-target": "node ./node_modules/webpack/bin/webpack.js --mode=production --config config/webpack.config.js", 13 | "build-chrome": "cross-env TARGET_BROWSER=chrome npm run build-target", 14 | "build-firefox": "cross-env TARGET_BROWSER=firefox npm run build-target", 15 | "build-copy": "npm run build && cp -rf ./build/chrome/* ../chrome-edge-workspaces.vscode/test-build", 16 | 17 | "lint": "eslint -f unix \"src/**/*.{ts,tsx}\"", 18 | "test": "jest .", 19 | "test-unit": "jest --testMatch='**/unit/**/*.test.[tj]s'", 20 | "test-e2e": "jest --testMatch='**/e2e/*.test.[tj]s'", 21 | "jest-unit": "jest --testMatch='**/unit/**/*.test.[tj]s'" 22 | }, 23 | "devDependencies": { 24 | "@types/chrome": "^0.0.268", 25 | "@types/jest": "^29.5.8", 26 | "@types/node": "^20.8.3", 27 | "@types/uuid": "^9.0.7", 28 | "@typescript-eslint/eslint-plugin": "^6.16.0", 29 | "@typescript-eslint/parser": "^6.16.0", 30 | "copy-webpack-plugin": "^6.4.1", 31 | "cross-env": "^7.0.3", 32 | "css-loader": "^6.8.1", 33 | "eslint": "^8.56.0", 34 | "eslint-plugin-tsdoc": "^0.2.17", 35 | "file-loader": "^6.2.0", 36 | "html-loader": "^4.2.0", 37 | "jest": "^29.7.0", 38 | "mini-css-extract-plugin": "^0.10.1", 39 | "process": "^0.11.10", 40 | "puppeteer": "^21.5.2", 41 | "size-plugin": "^2.0.2", 42 | "ts-jest": "^29.1.1", 43 | "ts-loader": "^9.5.0", 44 | "typescript": "^5.2.2", 45 | "util": "^0.12.5", 46 | "uuid": "^9.0.1", 47 | "webpack": "^5.88.2", 48 | "webpack-cli": "^4.10.0", 49 | "webpack-merge": "^5.9.0" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /public-flavors/chrome/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Edge Workspaces", 4 | "version": "1.2.1", 5 | "description": "Effortlessly organize and manage multiple projects or tasks by saving and restoring entire browsing sessions.", 6 | "icons": { 7 | "16": "icons/icon_16.png", 8 | "32": "icons/icon_32.png", 9 | "48": "icons/icon_48.png", 10 | "128": "icons/icon_128.png" 11 | }, 12 | "action": { 13 | "default_title": "Edge Workspaces", 14 | "default_popup": "popup.html" 15 | }, 16 | "host_permissions": [ 17 | "" 18 | ], 19 | "background": { 20 | "service_worker": "background.js", 21 | "type": "module" 22 | }, 23 | "permissions": [ 24 | "storage", 25 | "tabGroups", 26 | "activeTab", 27 | "bookmarks" 28 | ], 29 | "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvm1OPpzTP9VQFBmLUOmXMMlKZgL7EC848eFMnLznEPe8W/YcDUyHTTzpixOaj29JqNoGs5ehfN6fEOBHAwtRwYRLHkDgjh4GDpT8UwkDJp9Jj2Fmtt/vVQ5UIqPZGfNPiAJysifXvR6ngeNa4WSWh8kWJAGS3D6aWBuGIQGOnfSaxtBskrbE2XEWSg4vzHzTVJHQ9U0lLDMTD47Ghnsi+L1hdOrRvW5abQmISh2pM09YmEmkosqwa1cmVUHsguH8cI5mA9O4MMkjqiFuXrR9gTxzCPKQXDbKmcVMj6qraZgmvRYK5T2O0MB56WiGy3I3KyC4VCkLWcJDzeTCoJ/hQwIDAQAB" 30 | } -------------------------------------------------------------------------------- /public-flavors/chrome/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Edge Workspaces 7 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |

Edge Workspaces

17 |
18 | add_circle 19 | menu 20 |
21 |
22 |
23 | 24 | If you see this message, it means that the workspaces list is still loading.
25 | If this message persists, please file a bug report here. 26 |
27 | 28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /public-flavors/firefox/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "manifest_version": 3, 3 | "name": "Edge Workspaces", 4 | "version": "1.2.1", 5 | "description": "Effortlessly organize and manage multiple projects or tasks by saving and restoring entire browsing sessions.", 6 | "icons": { 7 | "16": "icons/icon_16.png", 8 | "32": "icons/icon_32.png", 9 | "48": "icons/icon_48.png", 10 | "128": "icons/icon_128.png" 11 | }, 12 | "action": { 13 | "default_title": "Edge Workspaces", 14 | "default_popup": "popup.html" 15 | }, 16 | "host_permissions": [ 17 | "" 18 | ], 19 | "background": { 20 | "scripts": [ 21 | "background.js" 22 | ], 23 | "type": "module" 24 | }, 25 | "permissions": [ 26 | "storage", 27 | "activeTab", 28 | "bookmarks" 29 | ], 30 | "browser_specific_settings": { 31 | "gecko": { 32 | "id": "edgeworkspaces@elec0.com", 33 | "strict_min_version": "109.0" 34 | } 35 | }, 36 | "sidebar_action": { 37 | "default_title": "Edge Workspaces", 38 | "default_panel": "sidebar.html", 39 | "default_icon": "icons/icon_48.png" 40 | } 41 | } -------------------------------------------------------------------------------- /public-flavors/firefox/popup-firefox.js: -------------------------------------------------------------------------------- 1 | (async function () { 2 | document.addEventListener("DOMContentLoaded", () => { 3 | const openSidebarBtn = document.getElementById("open-sidebar"); 4 | openSidebarBtn?.addEventListener("click", () => { 5 | browser.sidebarAction.open(); 6 | }); 7 | 8 | }); 9 | })(); -------------------------------------------------------------------------------- /public-flavors/firefox/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Edge Workspaces 7 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |

Edge Workspaces

17 |
18 | 21 | 22 | 23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /public-flavors/firefox/sidebar.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Edge Workspaces 7 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
16 |

Edge Workspaces

17 |
18 | add_circle 19 | 20 | menu 21 |
22 |
23 | 26 |
27 | 28 |
29 | 30 | 31 | -------------------------------------------------------------------------------- /public/icons/icon_128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/public/icons/icon_128.png -------------------------------------------------------------------------------- /public/icons/icon_16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/public/icons/icon_16.png -------------------------------------------------------------------------------- /public/icons/icon_32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/public/icons/icon_32.png -------------------------------------------------------------------------------- /public/icons/icon_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elec0/chrome-edge-workspaces/76da966c43c9804e36d2b6c535ff8ad731cf93c5/public/icons/icon_48.png -------------------------------------------------------------------------------- /scripts/sync-version.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This script updates the version in the manifest.json file with the version from package.json. 3 | */ 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | 7 | const packageJson = require('../package.json'); 8 | // Update the manifest files for the provided browser 9 | function updateManifest(browser) { 10 | const manifestPath = path.join(__dirname, '..', 'public-flavors', browser, 'manifest.json'); 11 | const manifest = require(manifestPath); 12 | 13 | manifest.version = packageJson.version; 14 | 15 | fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); 16 | } 17 | 18 | updateManifest('chrome'); 19 | updateManifest('firefox'); -------------------------------------------------------------------------------- /src/constants/constants.ts: -------------------------------------------------------------------------------- 1 | 2 | export class Constants { 3 | public static KEY_STORAGE_WORKSPACES = "workspaces"; 4 | 5 | public static BOOKMARKS_FOLDER_NAME = "Edge Workspaces (read-only)"; 6 | 7 | public static DOWNLOAD_FILENAME = "workspaces-export.json"; 8 | 9 | /** Time in seconds to delay saving a workspace after it has been opened. */ 10 | public static WORKSPACE_OPEN_SAVE_DELAY = 5; 11 | 12 | /** Time in ms to debounce the saving of the workspace on updates. */ 13 | public static WORKSPACE_SAVE_DEBOUNCE_TIME = 300; 14 | 15 | /** When a workspace is missing a last updated time, use this one instead of a default. */ 16 | public static FAR_IN_PAST_DATE = new Date(1970, 0, 1).getTime(); 17 | 18 | public static DEBOUNCE_IDS = { 19 | saveWorkspace: "saveWorkspace", 20 | saveWorkspaceToSync: "saveWorkspaceToSync", 21 | saveAllWorkspacesToSync: "saveAllWorkspacesToSync", 22 | } 23 | 24 | public static STORAGE_KEYS = { 25 | settings: { 26 | saveBookmarks: "settings.saveBookmarks", 27 | saveSync: "settings.saveSync", 28 | debug: "settings.debug", 29 | }, 30 | sync: { 31 | tombstones: "sync.tombstones", 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/constants/message-responses.ts: -------------------------------------------------------------------------------- 1 | 2 | export type MessageResponse = { [key: string]: string }; 3 | /** 4 | * Represents a collection of message responses. 5 | * TODO: This is kinda ass. There's no way to pass in custom messages or anything. Should rework it. 6 | */ 7 | export class MessageResponses { 8 | private static readonly _key = "message"; 9 | 10 | /** 11 | * Represents an OK response. 12 | */ 13 | public static readonly OK: MessageResponse = { [MessageResponses._key]: "OK" }; 14 | 15 | /** 16 | * Represents an ERROR response. 17 | */ 18 | public static readonly ERROR: MessageResponse = { [MessageResponses._key]: "ERROR" }; 19 | 20 | /** 21 | * Represents a DATA response, with the data being the value of the key. 22 | */ 23 | public static readonly DATA: MessageResponse = { [MessageResponses._key]: "" }; 24 | 25 | /** 26 | * Represents a SUCCESS response. 27 | */ 28 | public static readonly SUCCESS: MessageResponse = { [MessageResponses._key]: "SUCCESS"} 29 | 30 | public static readonly UNKNOWN_MSG: MessageResponse = { [MessageResponses._key]: "UNKNOWN message" } 31 | 32 | } -------------------------------------------------------------------------------- /src/constants/messages.ts: -------------------------------------------------------------------------------- 1 | export class Messages { 2 | public static MSG_NEW_WORKSPACE = "NEW_WORKSPACE"; 3 | /** 4 | * The message type for creating a new workspace from a window. 5 | * @see BackgroundMessageHandlers.processNewWorkspaceFromWindow 6 | * @see PopupMessageHelper.sendNewWorkspaceFromWindow 7 | * 8 | * The popup script doesn't send the list of tabs to the background script, 9 | * rather the background script gets the tabs from the windowId. 10 | */ 11 | public static MSG_NEW_WORKSPACE_FROM_WINDOW = "NEW_WORKSPACE_FROM_WINDOW"; 12 | /** 13 | * 14 | * Send a message to the background script requesting all workspace data. 15 | * @returns A Promise that resolves to a MessageResponse object, containing a 16 | * @see BackgroundMessageHandlers.processGetWorkspaces 17 | * @see PopupMessageHelper.sendGetWorkspaces 18 | */ 19 | public static MSG_GET_WORKSPACES = "GET_WORKSPACES"; 20 | public static MSG_GET_WORKSPACE = "GET_WORKSPACE"; 21 | public static MSG_OPEN_WORKSPACE = "OPEN_WORKSPACE"; 22 | public static MSG_CLEAR_WORKSPACES = "CLEAR_WORKSPACES"; 23 | public static MSG_DELETE_WORKSPACE = "DELETE_WORKSPACE"; 24 | public static MSG_RENAME_WORKSPACE = "RENAME_WORKSPACE"; 25 | } -------------------------------------------------------------------------------- /src/dialogs/base-dialog.ts: -------------------------------------------------------------------------------- 1 | 2 | /** 3 | * Represents a base dialog. 4 | */ 5 | export abstract class BaseDialog { 6 | 7 | public abstract open(): void; 8 | 9 | /** 10 | * Closes the dialog and optionally resolves a value. 11 | * @param dialogElement - The HTML dialog element. 12 | * @param resolve - Optional. A callback function to resolve a value. 13 | */ 14 | protected static cancelCloseDialog(dialogElement: HTMLDialogElement, resolve?: (value: string | null) => void) { 15 | if (resolve) { 16 | resolve(null); 17 | } 18 | dialogElement.close(); 19 | dialogElement.remove(); 20 | } 21 | } -------------------------------------------------------------------------------- /src/globals.ts: -------------------------------------------------------------------------------- 1 | declare const VERSION: string; 2 | const _version = VERSION; 3 | export { _version as VERSION }; 4 | -------------------------------------------------------------------------------- /src/interfaces/i-stub.ts: -------------------------------------------------------------------------------- 1 | export interface IStub { 2 | toJson(): string; 3 | [key: string]: unknown; 4 | } -------------------------------------------------------------------------------- /src/interfaces/i-tab-json.ts: -------------------------------------------------------------------------------- 1 | // Define an interface for the tab objects 2 | export interface ITabJson { 3 | // Add the properties of your tab objects here 4 | // For example: 5 | id: string; 6 | url: string; 7 | } -------------------------------------------------------------------------------- /src/interfaces/i-workspace-json.ts: -------------------------------------------------------------------------------- 1 | 2 | // Define an interface for the JSON object 3 | export interface IWorkspaceJson { 4 | id: number; 5 | name: string; 6 | uuid: string; 7 | tabs: string[]; 8 | tabGroups: string[]; 9 | lastUpdated?: number; 10 | } 11 | 12 | -------------------------------------------------------------------------------- /src/interfaces/messages.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface IRequest { 3 | type: string; 4 | payload: unknown; 5 | } 6 | /** 7 | * The request object interface for the {@link Messages.MSG_OPEN_WORKSPACE} message. 8 | */ 9 | export interface IRequestOpenWorkspace extends IRequest { 10 | payload: { 11 | uuid: string; 12 | windowId: number; 13 | }; 14 | } 15 | 16 | export interface IRequestWithNameId extends IRequest { 17 | payload: { 18 | workspaceName: string; 19 | windowId: number; 20 | }; 21 | } 22 | 23 | export interface IRequestWithUuid extends IRequest { 24 | payload: { 25 | uuid: string; 26 | }; 27 | } 28 | 29 | export interface IRequestRenameWorkspace extends IRequest { 30 | payload: { 31 | uuid: string; 32 | newName: string; 33 | }; 34 | } -------------------------------------------------------------------------------- /src/log-helper.ts: -------------------------------------------------------------------------------- 1 | import * as util from 'util'; 2 | 3 | export class LogHelper { 4 | private static readonly ENABLE_TRACE = true; 5 | 6 | public static log(message: string, ...optionalParams: unknown[]): void { 7 | console.log(message, ...optionalParams); 8 | } 9 | 10 | /** 11 | * Log an error to the console and display an alert. 12 | * @param message - The message to log. 13 | * @param optionalParams - Optional parameters to pass to util.format 14 | */ 15 | public static errorAlert(message: string, ...optionalParams: unknown[]): void { 16 | const formattedMessage = util.format(message, ...optionalParams); 17 | console.error(formattedMessage); 18 | alert(formattedMessage); 19 | } 20 | 21 | public static successAlert(message: string, ...optionalParams: unknown[]): void { 22 | const formattedMessage = util.format(message, ...optionalParams); 23 | console.log(formattedMessage); 24 | alert(formattedMessage); 25 | } 26 | 27 | public static warn(message: string, ...optionalParams: unknown[]): void { 28 | console.warn(message, ...optionalParams); 29 | } 30 | 31 | public static trace(message: string, ...optionalParams: unknown[]): void { 32 | if (this.ENABLE_TRACE) { 33 | console.trace(message, ...optionalParams); 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /src/messages/background-message-handlers.ts: -------------------------------------------------------------------------------- 1 | import { Messages } from "../constants/messages"; 2 | import { IRequest, IRequestWithUuid, IRequestWithNameId, IRequestOpenWorkspace, IRequestRenameWorkspace } from "../interfaces/messages"; 3 | import { StorageHelper } from "../storage-helper"; 4 | import { Background } from "../background"; 5 | import { MessageResponse, MessageResponses } from "../constants/message-responses"; 6 | import { Utils } from "../utils"; 7 | import { BookmarkStorageHelper } from "../storage/bookmark-storage-helper"; 8 | 9 | /** 10 | * Class representing the message handlers for background operations. 11 | * 12 | * @remarks This class is public for testing purposes. 13 | */ 14 | 15 | export class BackgroundMessageHandlers { 16 | /** 17 | * We're being informed that a workspace is being opened in a new window. 18 | * @param request - The request object containing the workspace data. 19 | */ 20 | public static async processOpenWorkspace(request: IRequestOpenWorkspace): Promise { 21 | if (!request?.payload?.uuid || !request?.payload?.windowId) { 22 | return MessageResponses.ERROR; 23 | } 24 | 25 | return await Background.updateWorkspaceWindowId(request.payload.uuid, request.payload.windowId); 26 | } 27 | 28 | /** 29 | * Processes a new workspace request. 30 | * @param request - The request object containing the workspace name and window ID. 31 | * @returns A promise that resolves to a MessageResponse indicating the success or failure of the operation. 32 | */ 33 | public static async processNewWorkspace(request: IRequestWithNameId): Promise { 34 | const result = await StorageHelper.addWorkspace(request.payload.workspaceName, request.payload.windowId); 35 | if (!result) { 36 | return MessageResponses.ERROR; 37 | } 38 | return MessageResponses.SUCCESS; 39 | } 40 | 41 | /** 42 | * Processes a new workspace from window request. 43 | * Create a new workspace via `processNewWorkspace`, then save the tabs from the window to the workspace. 44 | * 45 | * @param request - The request object containing the workspace name and window ID. 46 | * @returns A promise that resolves to a MessageResponse indicating the success or failure of the operation. 47 | */ 48 | public static async processNewWorkspaceFromWindow(request: IRequestWithNameId): Promise { 49 | // Reuse the existing workspace creation logic 50 | if (await this.processNewWorkspace(request) === MessageResponses.ERROR) { 51 | return MessageResponses.ERROR; 52 | } 53 | 54 | await Background.saveWindowTabsToWorkspace(request.payload.windowId); 55 | return MessageResponses.SUCCESS; 56 | } 57 | 58 | /** 59 | * Processes a request to delete a workspace. 60 | * @param request - The request object containing the workspace UUID to delete. 61 | */ 62 | public static async processDeleteWorkspace(request: IRequestWithUuid): Promise { 63 | // Get the windowId from the workspace before we delete it, so we can clear the badge 64 | // just in case the workspace is open when it's deleted. 65 | const workspace = await StorageHelper.getWorkspace(request.payload.uuid); 66 | if (workspace) { 67 | Utils.clearBadgeForWindow(workspace.windowId); 68 | } 69 | 70 | const result = await StorageHelper.removeWorkspace(request.payload.uuid); 71 | 72 | // Remove the workspace from bookmarks 73 | await BookmarkStorageHelper.removeWorkspace(workspace); 74 | 75 | if (!result) { 76 | return MessageResponses.ERROR; 77 | } 78 | return MessageResponses.SUCCESS; 79 | } 80 | 81 | /** 82 | * Processes a request to rename a workspace. 83 | * @param request - The request object containing the workspace UUID and the new name. 84 | */ 85 | public static async processRenameWorkspace(request: IRequestRenameWorkspace): Promise { 86 | const result = await StorageHelper.renameWorkspace(request.payload.uuid, request.payload.newName); 87 | if (!result) { 88 | return MessageResponses.ERROR; 89 | } 90 | return MessageResponses.SUCCESS; 91 | } 92 | 93 | /** 94 | * Processes the request to get the workspaces. 95 | * @param request - The request object, unused. 96 | * @returns A promise that resolves to a MessageResponse containing the serialized workspaces data. 97 | */ 98 | public static async processGetWorkspaces(_request: unknown): Promise { 99 | const workspaces = await StorageHelper.getWorkspaces(); 100 | return { "data": workspaces.serialize() }; 101 | } 102 | 103 | /** 104 | * Processes the request to get a workspace. 105 | * @param request - The request object. 106 | * @returns A promise that resolves to a MessageResponse containing the serialized workspace data. 107 | */ 108 | public static async processGetWorkspace(request: IRequestWithUuid): Promise { 109 | if (!request?.payload?.uuid) { 110 | return MessageResponses.ERROR; 111 | } 112 | const workspace = await StorageHelper.getWorkspace(request.payload.uuid); 113 | return { "data": workspace.serialize() }; 114 | } 115 | 116 | public static async processClearWorkspaces(_request: unknown): Promise { 117 | await StorageHelper.clearWorkspaces(); 118 | return MessageResponses.SUCCESS; 119 | } 120 | 121 | /** 122 | * Handles incoming messages from the content script. 123 | * Messages are sent from {@link PopupMessageHelper}. 124 | * @param request - The message request object. 125 | * @param sender - The sender of the message. 126 | * @param sendResponse - The function to send a response back to the content script. 127 | * @returns A boolean indicating whether the message was successfully handled. 128 | */ 129 | public static messageListener(request: IRequest, _sender: unknown, sendResponse: (response: MessageResponse) => void): boolean { 130 | switch (request.type) { 131 | case Messages.MSG_GET_WORKSPACES: 132 | BackgroundMessageHandlers.processGetWorkspaces(request).then(sendResponse); 133 | return true; 134 | 135 | case Messages.MSG_GET_WORKSPACE: 136 | BackgroundMessageHandlers.processGetWorkspace(request as IRequestWithUuid).then(sendResponse); 137 | return true; 138 | 139 | case Messages.MSG_NEW_WORKSPACE: 140 | BackgroundMessageHandlers.processNewWorkspace(request as IRequestWithNameId).then(sendResponse); 141 | return true; 142 | 143 | case Messages.MSG_NEW_WORKSPACE_FROM_WINDOW: 144 | BackgroundMessageHandlers.processNewWorkspaceFromWindow(request as IRequestWithNameId).then(sendResponse); 145 | return true; 146 | 147 | case Messages.MSG_OPEN_WORKSPACE: 148 | BackgroundMessageHandlers.processOpenWorkspace(request as IRequestOpenWorkspace).then(sendResponse); 149 | return true; 150 | 151 | case Messages.MSG_DELETE_WORKSPACE: 152 | BackgroundMessageHandlers.processDeleteWorkspace(request as IRequestWithUuid).then(sendResponse); 153 | return true; 154 | 155 | case Messages.MSG_RENAME_WORKSPACE: 156 | BackgroundMessageHandlers.processRenameWorkspace(request as IRequestRenameWorkspace).then(sendResponse); 157 | return true; 158 | 159 | case Messages.MSG_CLEAR_WORKSPACES: 160 | BackgroundMessageHandlers.processClearWorkspaces(request).then(sendResponse); 161 | return true; 162 | } 163 | 164 | console.log(MessageResponses.UNKNOWN_MSG.message, "for request:", request); 165 | sendResponse(MessageResponses.UNKNOWN_MSG); 166 | return false; 167 | } 168 | } 169 | -------------------------------------------------------------------------------- /src/messages/popup-message-helper.ts: -------------------------------------------------------------------------------- 1 | import { MessageResponse, MessageResponses } from "../constants/message-responses"; 2 | import { Messages } from "../constants/messages"; 3 | import { IRequestWithUuid, IRequestWithNameId, IRequestOpenWorkspace, IRequest } from "../interfaces/messages"; 4 | import { LogHelper } from "../log-helper"; 5 | 6 | export class PopupMessageHelper { 7 | public static async sendAddNewWorkspaceFromWindow(workspaceName: string, windowId: number): Promise { 8 | const message: IRequestWithNameId = { 9 | type: Messages.MSG_NEW_WORKSPACE_FROM_WINDOW, 10 | payload: { workspaceName, windowId: windowId } 11 | }; 12 | 13 | return PopupMessageHelper.doSendMessage(message); 14 | } 15 | 16 | public static async sendAddNewWorkspace(workspaceName: string, windowId: number): Promise { 17 | const message: IRequestWithNameId = { 18 | type: Messages.MSG_NEW_WORKSPACE, 19 | payload: { workspaceName, windowId: windowId } 20 | }; 21 | 22 | return PopupMessageHelper.doSendMessage(message); 23 | } 24 | 25 | public static async sendOpenWorkspace(workspaceUuid: string, windowId: number): Promise { 26 | const message: IRequestOpenWorkspace = { 27 | type: Messages.MSG_OPEN_WORKSPACE, 28 | payload: { uuid: workspaceUuid, windowId: windowId } 29 | }; 30 | 31 | return PopupMessageHelper.doSendMessage(message); 32 | } 33 | 34 | /** 35 | * Sends a message to get workspaces and returns the response. 36 | * @returns A Promise that resolves to a MessageResponse object that should contain the workspaces. 37 | */ 38 | public static async sendGetWorkspaces(): Promise { 39 | const response = await chrome.runtime.sendMessage({ 40 | type: Messages.MSG_GET_WORKSPACES, 41 | payload: {} 42 | }); 43 | 44 | if (response === undefined) { 45 | LogHelper.errorAlert("Error getting workspaces. Check the console for more details."); 46 | console.error("Response was undefined"); 47 | return MessageResponses.ERROR; 48 | } 49 | if (response.data == null || response.data === undefined) { 50 | LogHelper.errorAlert("Error getting workspaces. Check the console for more details."); 51 | console.error("Response data was undefined"); 52 | return MessageResponses.ERROR; 53 | } 54 | console.debug("getWorkspaces response", response); 55 | return response; 56 | } 57 | 58 | public static async sendGetWorkspace(workspaceUuid: string): Promise { 59 | const message = { 60 | type: Messages.MSG_GET_WORKSPACE, 61 | payload: { uuid: workspaceUuid } 62 | }; 63 | 64 | return PopupMessageHelper.doSendMessage(message); 65 | } 66 | 67 | /** 68 | * Sends a message to clear workspaces. 69 | * @returns A promise that resolves to a MessageResponse. 70 | */ 71 | public static async sendClearWorkspaces(): Promise { 72 | const message = { 73 | type: Messages.MSG_CLEAR_WORKSPACES, 74 | payload: {} 75 | }; 76 | 77 | return PopupMessageHelper.doSendMessage(message); 78 | } 79 | 80 | /** 81 | * Sends a message to delete a workspace. 82 | * @param workspaceUuid - The UUID of the workspace to delete. 83 | * @returns A promise that resolves to a MessageResponse. 84 | */ 85 | public static async sendDeleteWorkspace(workspaceUuid: string): Promise { 86 | const message: IRequestWithUuid = { 87 | type: Messages.MSG_DELETE_WORKSPACE, 88 | payload: { uuid: workspaceUuid } 89 | }; 90 | 91 | return PopupMessageHelper.doSendMessage(message); 92 | } 93 | 94 | public static async sendRenameWorkspace(workspaceUuid: string, newName: string): Promise { 95 | const message = { 96 | type: Messages.MSG_RENAME_WORKSPACE, 97 | payload: { uuid: workspaceUuid, newName: newName } 98 | }; 99 | 100 | return PopupMessageHelper.doSendMessage(message); 101 | } 102 | 103 | /** 104 | * Sends a message to the background script using the Chrome runtime API. 105 | * @param message - The message to send. 106 | * @returns A promise that resolves to the response from the background script. 107 | */ 108 | private static async doSendMessage(message: IRequest): Promise { 109 | const response = await chrome.runtime.sendMessage(message); 110 | 111 | if (response === undefined) { 112 | console.error("Response was undefined"); 113 | return MessageResponses.ERROR; 114 | } 115 | 116 | return response; 117 | } 118 | } -------------------------------------------------------------------------------- /src/obj/tab-group-stub.ts: -------------------------------------------------------------------------------- 1 | import { IStub } from "../interfaces/i-stub"; 2 | 3 | /** 4 | * Instead of trying to serialize the entire TabGroup object, we just serialize the 5 | * properties we need. 6 | */ 7 | export class TabGroupStub implements IStub { 8 | public id: number = -1; 9 | public title: string = ""; 10 | public color: string = ""; 11 | public collapsed: boolean = false; 12 | public windowId: number = -1; 13 | 14 | [key: string]: unknown; 15 | 16 | private static propertiesToExtract: string[] = [ 17 | "id", 18 | "title", 19 | "color", 20 | "collapsed", 21 | "windowId", 22 | ]; 23 | 24 | private constructor(tabGroup: Partial) { 25 | for (const prop of TabGroupStub.propertiesToExtract) { 26 | if (tabGroup[prop as keyof chrome.tabGroups.TabGroup] !== undefined) { 27 | this[prop] = tabGroup[prop as keyof chrome.tabGroups.TabGroup]; 28 | } 29 | } 30 | } 31 | 32 | public toJson(): string { 33 | return JSON.stringify(this); 34 | } 35 | 36 | public static fromTabGroup(tabGroup: chrome.tabGroups.TabGroup): TabGroupStub { 37 | return new TabGroupStub(tabGroup); 38 | } 39 | 40 | public static fromJson(json: string): TabGroupStub { 41 | return new TabGroupStub(JSON.parse(json)); 42 | } 43 | 44 | public static fromTabGroups(tabGroups: chrome.tabGroups.TabGroup[]): TabGroupStub[] { 45 | return tabGroups.map(tabGroup => new TabGroupStub(tabGroup)); 46 | } 47 | } -------------------------------------------------------------------------------- /src/obj/tab-stub.ts: -------------------------------------------------------------------------------- 1 | import { IStub } from "../interfaces/i-stub"; 2 | 3 | /** 4 | * Instead of trying to serialize the entire Tab object, we just serialize the 5 | * properties we need. 6 | */ 7 | export class TabStub implements IStub { 8 | public id: number = -1; 9 | public index: number = -1; 10 | public title: string = ""; 11 | public url: string = ""; 12 | public favIconUrl: string = ""; 13 | public pinned: boolean = false; 14 | public windowId: number = -1; 15 | public active: boolean = false; 16 | public mutedInfo?: chrome.tabs.MutedInfo; 17 | public groupId?: number = chrome.tabGroups?.TAB_GROUP_ID_NONE; 18 | 19 | [key: string]: unknown; 20 | 21 | private static propertiesToExtract: string[] = [ 22 | "id", 23 | "index", 24 | "title", 25 | "url", 26 | "favIconUrl", 27 | "pinned", 28 | "windowId", 29 | "active", 30 | "mutedInfo", 31 | "groupId" 32 | ]; 33 | 34 | private constructor(tab: Partial) { 35 | for (const prop of TabStub.propertiesToExtract) { 36 | if (tab[prop as keyof chrome.tabs.Tab] !== undefined) { 37 | this[prop] = tab[prop as keyof chrome.tabs.Tab]; 38 | } 39 | } 40 | } 41 | 42 | /** 43 | * Used to serialize the TabStub to a JSON string. 44 | * 45 | * @param replacer - An optional parameter that can be used to manipulate the serialization process. 46 | * @returns 47 | */ 48 | public toJson(replacer?: (this: unknown, key: string, value: unknown) => unknown): string { 49 | return JSON.stringify(this, replacer); 50 | } 51 | 52 | public static fromTab(tab: chrome.tabs.Tab): TabStub { 53 | return new TabStub(tab); 54 | } 55 | 56 | public static fromJson(json: string): TabStub { 57 | return new TabStub(JSON.parse(json)); 58 | } 59 | 60 | public static fromTabs(tabs: chrome.tabs.Tab[]): TabStub[] { 61 | return tabs.map(tab => new TabStub(tab)); 62 | } 63 | } -------------------------------------------------------------------------------- /src/obj/workspace.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from "uuid"; 2 | import { IWorkspaceJson } from "../interfaces/i-workspace-json"; 3 | import { TabStub } from "./tab-stub"; 4 | import { TabGroupStub } from "./tab-group-stub"; 5 | import { Constants } from "../constants/constants"; 6 | 7 | 8 | /** 9 | * Represents a workspace. 10 | */ 11 | export class Workspace { 12 | 13 | public uuid: string; 14 | public windowId: number; 15 | public name: string; 16 | private tabs: Map; 17 | /** The tab groups that exist in this workspace/window, mapped by their IDs */ 18 | private tabGroups: Map; 19 | public lastUpdated: number; 20 | 21 | constructor(windowId: number, name: string, tabs: chrome.tabs.Tab[] | undefined = undefined, 22 | tabStubs: TabStub[] | undefined = undefined, uuid: string | undefined = uuidv4(), 23 | tabGroups: Map | undefined = undefined, 24 | lastUpdated?: number 25 | ) { 26 | this.windowId = windowId; 27 | this.name = name; 28 | this.uuid = uuid; 29 | this.tabs = new Map(); 30 | this.tabGroups = new Map(); 31 | 32 | if (tabs != undefined) { 33 | tabs.forEach((tab: chrome.tabs.Tab) => { 34 | this.addTab(undefined, tab); 35 | }); 36 | } else if (tabStubs != undefined) { 37 | tabStubs.forEach((tabStub: TabStub) => { 38 | this.tabs.set(tabStub.id, tabStub); 39 | }); 40 | } 41 | 42 | if (tabGroups != undefined) { 43 | this.tabGroups = tabGroups; 44 | } 45 | 46 | this.lastUpdated = lastUpdated ?? Constants.FAR_IN_PAST_DATE; 47 | } 48 | 49 | 50 | /** 51 | * Adds a tab to the workspace. 52 | * @param tabStub - The tab stub to add. If not provided, it will be created from the chrome tab. 53 | * @param chromeTab - The chrome tab to create the tab stub from. If not provided, it will be created from the tab stub. 54 | * @throws Error if either tabStub or chromeTab is not defined. 55 | */ 56 | public addTab(tabStub?: TabStub, chromeTab?: chrome.tabs.Tab): void { 57 | let tabStubToAdd: TabStub; 58 | if (tabStub != undefined) { 59 | tabStubToAdd = tabStub; 60 | } else if (chromeTab != undefined) { 61 | tabStubToAdd = TabStub.fromTab(chromeTab); 62 | } else { 63 | throw new Error("Either tabStub or tab must be defined."); 64 | } 65 | if (tabStubToAdd.url == undefined || tabStubToAdd.url.length == 0) { 66 | console.warn(`Tab id=${ tabStubToAdd.id } URL is empty. This tab will not be added to the workspace.`); 67 | } 68 | else { 69 | this.tabs.set(tabStubToAdd.id, tabStubToAdd); 70 | } 71 | } 72 | 73 | public removeTab(tabId: number): void { 74 | this.tabs.delete(tabId); 75 | } 76 | 77 | public clearTabs(): void { 78 | this.tabs.clear(); 79 | } 80 | 81 | /** 82 | * Retrieves a tab by its ID. 83 | * @param tabId - The ID of the tab to retrieve. 84 | * @returns The tab with the specified ID, or undefined if not found. 85 | */ 86 | public getTab(tabId: number): TabStub | undefined { 87 | return this.tabs.get(tabId); 88 | } 89 | 90 | /** 91 | * Retrieves an array of TabStub objects representing the tabs in the workspace. 92 | * The tabs are ordered by their index property. 93 | * 94 | * @returns An array of TabStub objects. 95 | */ 96 | public getTabs(): TabStub[] { 97 | const tabs = Array.from(this.tabs.values()); 98 | tabs.sort((a: TabStub, b: TabStub) => a.index - b.index); 99 | return tabs; 100 | } 101 | 102 | /** 103 | * Retrieves the tab groups in the workspace. 104 | * @returns An array of TabGroupStub objects. 105 | */ 106 | public getTabGroups(): TabGroupStub[] { 107 | return Array.from(this.tabGroups.values()); 108 | } 109 | 110 | /** 111 | * Adds a tab group to the workspace. 112 | * @param tabGroup - The tab group to add. 113 | */ 114 | public addTabGroup(tabGroup: TabGroupStub): void { 115 | this.tabGroups.set(tabGroup.id, tabGroup); 116 | } 117 | 118 | /** 119 | * Removes a tab group from the workspace. 120 | * @param tabGroupId - The ID of the tab group to remove. 121 | */ 122 | public removeTabGroup(tabGroupId: number): void { 123 | this.tabGroups.delete(tabGroupId); 124 | } 125 | 126 | /** 127 | * Replace the tab groups in the workspace with the provided tab groups. 128 | */ 129 | public setTabGroups(tabGroups: TabGroupStub[]): void { 130 | this.tabGroups.clear(); 131 | tabGroups.forEach((tabGroup: TabGroupStub) => { 132 | this.addTabGroup(tabGroup); 133 | }); 134 | } 135 | 136 | /** 137 | * Renames the workspace. 138 | * @param newName - The new name of the workspace. 139 | */ 140 | public updateName(newName: string): void { 141 | this.name = newName; 142 | } 143 | 144 | /** 145 | * Updates the last updated timestamp of the workspace. 146 | * 147 | * Should be called whenever the workspace is modified in any 148 | * way not related to loading from storage. 149 | */ 150 | public updateLastUpdated(): void { 151 | this.lastUpdated = Date.now(); 152 | } 153 | 154 | /** 155 | * Replace the tabs in the workspace with the provided tabs. 156 | */ 157 | public setTabs(tabs: TabStub[]): void { 158 | this.clearTabs(); 159 | tabs.forEach((tab: TabStub) => { 160 | this.addTab(tab); 161 | }); 162 | this.ensureTabIndexesOrdered(); 163 | } 164 | 165 | /** 166 | * Ensure that the tab.index values are ordered correctly from 0 to n. 167 | * 168 | * The indexes can be out of order if there is an untrackable tab open. 169 | */ 170 | private ensureTabIndexesOrdered(): void { 171 | let index = 0; 172 | this.getTabs().forEach(tab => { 173 | tab.index = index; 174 | index++; 175 | }); 176 | } 177 | 178 | public toJsonObject(): object { 179 | this.ensureTabIndexesOrdered(); 180 | return { 181 | id: this.windowId, 182 | name: this.name, 183 | uuid: this.uuid, 184 | lastUpdated: this.lastUpdated, 185 | tabs: this.getTabs().map((tab: TabStub) => tab.toJson()), 186 | tabGroups: this.getTabGroups().map((tabGroup: TabGroupStub) => tabGroup.toJson()) 187 | }; 188 | } 189 | 190 | /** 191 | * Creates a Workspace object from a JSON representation. 192 | * @param json - The JSON object representing the Workspace. 193 | * @returns A new Workspace object. 194 | */ 195 | public static fromJson(json: IWorkspaceJson): Workspace { 196 | const workspace = new Workspace(json.id, json.name, undefined, undefined, json.uuid); 197 | if (json.lastUpdated !== undefined) { 198 | workspace.lastUpdated = json.lastUpdated; 199 | } 200 | 201 | if (json.tabs != null && json.tabs instanceof Array) { 202 | json.tabs.forEach((tab: string) => { 203 | workspace.addTab(TabStub.fromJson(tab)); 204 | }); 205 | } 206 | if (json.tabGroups != null && json.tabGroups instanceof Array) { 207 | json.tabGroups.forEach((tabGroup: string) => { 208 | workspace.addTabGroup(TabGroupStub.fromJson(tabGroup)); 209 | }); 210 | } 211 | return workspace; 212 | } 213 | 214 | public serialize(): string { 215 | return JSON.stringify(this.toJsonObject()); 216 | } 217 | 218 | public static deserialize(serialized: string): Workspace { 219 | return Workspace.fromJson(JSON.parse(serialized)); 220 | } 221 | } -------------------------------------------------------------------------------- /src/pages/page-add-workspace.ts: -------------------------------------------------------------------------------- 1 | import { BaseDialog } from "../dialogs/base-dialog"; 2 | import { Utils } from "../utils"; 3 | import ADD_WORKSPACE_TEMPLATE from "../templates/dialogAddWorkspaceTemplate.html"; 4 | import { Prompt } from "../utils/prompt"; 5 | import { LogHelper } from "../log-helper"; 6 | import { PopupActions } from "../popup-actions"; 7 | 8 | 9 | export class PageAddWorkspace extends BaseDialog { 10 | /** 11 | * Open the add new workspace dialog. 12 | */ 13 | public async open(): Promise { 14 | 15 | const dialog = Utils.interpolateTemplate(ADD_WORKSPACE_TEMPLATE, { "": "" }); 16 | 17 | const tempDiv = document.createElement('div'); 18 | tempDiv.innerHTML = dialog; 19 | 20 | const dialogElement = tempDiv.firstElementChild as HTMLDialogElement; 21 | const newWorkspaceButton = dialogElement.querySelector("#modal-new-workspace") as HTMLButtonElement; 22 | const newWorkspaceFromWindowButton = dialogElement.querySelector("#modal-new-workspace-from-window") as HTMLButtonElement; 23 | 24 | newWorkspaceButton?.addEventListener("click", (e) => { 25 | this.clickNewWorkspaceButton(e, dialogElement); 26 | }); 27 | 28 | newWorkspaceFromWindowButton?.addEventListener("click", (e) => { 29 | this.clickNewWorkspaceFromWindowButton(e, dialogElement); 30 | }); 31 | 32 | // Close the dialog when the close button is clicked, or when the dialog is cancelled (esc). 33 | dialogElement.querySelector("#modal-settings-close")?.addEventListener("click", () => { 34 | BaseDialog.cancelCloseDialog(dialogElement); 35 | }); 36 | dialogElement.addEventListener("cancel", () => { 37 | BaseDialog.cancelCloseDialog(dialogElement); 38 | }); 39 | 40 | document.body.appendChild(dialogElement); 41 | document.querySelector("dialog")?.showModal(); 42 | } 43 | 44 | /** 45 | * Create a new workspace in a new window. 46 | * Get the name of the new workspace from the user, then create a new window and add it to the workspace. 47 | */ 48 | private async clickNewWorkspaceButton(e: MouseEvent, dialogElement: HTMLDialogElement): Promise { 49 | e.preventDefault(); 50 | 51 | const workspaceData = await this.promptForWorkspaceName(); 52 | if (!workspaceData) { 53 | return; 54 | } 55 | 56 | const window = await chrome.windows.create({}); 57 | if (window.id === undefined) { 58 | console.error("New window ID is undefined"); 59 | LogHelper.errorAlert("Error creating new workspace. Check the console for more details."); 60 | return; 61 | } 62 | 63 | console.log(`window created, adding to workspace ${ workspaceData[0] }`); 64 | 65 | PopupActions.addNewWorkspace(workspaceData[0], window.id); 66 | BaseDialog.cancelCloseDialog(dialogElement); 67 | } 68 | 69 | /** 70 | * Create a new workspace with the tabs from the current window. 71 | */ 72 | private async clickNewWorkspaceFromWindowButton(e: MouseEvent, dialogElement: HTMLDialogElement): Promise { 73 | e.preventDefault(); 74 | const workspaceData = await this.promptForWorkspaceName(); 75 | if (!workspaceData) { 76 | return; 77 | } 78 | PopupActions.addNewWorkspaceFromWindow(workspaceData[0], workspaceData[1]); 79 | BaseDialog.cancelCloseDialog(dialogElement); 80 | } 81 | 82 | /** 83 | * Prompts the user to enter a name for a new workspace. 84 | * 85 | * @returns A promise that resolves to an array containing the workspace name and the current window ID, 86 | * or undefined if the prompt was cancelled or an error occurred. 87 | */ 88 | private async promptForWorkspaceName(): Promise<[string, number] | undefined> { 89 | // Present popup asking for workspace name 90 | const workspaceName = await Prompt.createPrompt("Enter a name for the new workspace"); 91 | 92 | if (workspaceName === null) { 93 | console.debug("New workspace prompt cancelled"); 94 | return; 95 | } 96 | if (workspaceName === "") { 97 | LogHelper.errorAlert("Workspace name cannot be empty"); 98 | return; 99 | } 100 | // Get the current window ID 101 | const currentWindow = await chrome.windows.getCurrent(); 102 | if (currentWindow === undefined || currentWindow.id === undefined) { 103 | console.error("Current window or id is undefined"); 104 | LogHelper.errorAlert("Error creating new workspace. Check the console for more details."); 105 | return; 106 | } 107 | return [workspaceName, currentWindow.id]; 108 | } 109 | } -------------------------------------------------------------------------------- /src/pages/page-settings.ts: -------------------------------------------------------------------------------- 1 | import { Constants } from "../constants/constants"; 2 | import { MessageResponse } from "../constants/message-responses"; 3 | import { BaseDialog } from "../dialogs/base-dialog"; 4 | import { VERSION } from "../globals"; 5 | import { LogHelper } from "../log-helper"; 6 | import { StorageHelper } from "../storage-helper"; 7 | import { BookmarkStorageHelper } from "../storage/bookmark-storage-helper"; 8 | import { DebugStorageHelper } from "../storage/debug-storage-helpet"; 9 | import { SyncWorkspaceStorage } from "../storage/sync-workspace-storage"; 10 | import SETTINGS_TEMPLATE from "../templates/dialogSettingsTemplate.html"; 11 | import { Utils } from "../utils"; 12 | 13 | /** 14 | * Represents the PageSettings class which extends the BaseDialog class. 15 | * This class provides functionality for opening the settings dialog and handling its events. 16 | */ 17 | export class PageSettings extends BaseDialog { 18 | 19 | public open(): void { 20 | PageSettings.openSettings(); 21 | } 22 | 23 | /** 24 | * Opens the settings dialog. 25 | * This method creates the dialog element, attaches event listeners, and shows the dialog. 26 | */ 27 | public static async openSettings() { 28 | const saveBookmarks = await BookmarkStorageHelper.isBookmarkSaveEnabled(); 29 | const syncWorkspaces = await SyncWorkspaceStorage.isSyncSavingEnabled(); 30 | const debugEnabled = await DebugStorageHelper.isDebugEnabled(); 31 | const dialog = Utils.interpolateTemplate(SETTINGS_TEMPLATE, 32 | { 33 | "version": VERSION, 34 | "bookmarkSaveChecked": saveBookmarks ? "checked" : "", 35 | "syncSaveChecked": syncWorkspaces ? "checked" : "", 36 | "debugChecked": debugEnabled ? "checked" : "", 37 | "debugVisibility": debugEnabled ? "block" : "none", 38 | } 39 | ); 40 | 41 | const tempDiv = document.createElement('div'); 42 | tempDiv.innerHTML = dialog; 43 | const dialogElement = tempDiv.firstElementChild as HTMLDialogElement; 44 | 45 | // Event listeners are in the same order as the buttons in the dialog. 46 | dialogElement.querySelector("#modal-settings-bookmark-save")?.addEventListener("click", async (event) => { 47 | const target = event.target as HTMLInputElement; 48 | await BookmarkStorageHelper.setBookmarkSaveEnabled(target.checked); 49 | }); 50 | dialogElement.querySelector("#modal-settings-sync-save")?.addEventListener("click", async (event) => { 51 | const target = event.target as HTMLInputElement; 52 | await SyncWorkspaceStorage.setSyncSavingEnabled(target.checked); 53 | }); 54 | dialogElement.querySelector("#modal-settings-debug")?.addEventListener("click", async (event) => { 55 | const target = event.target as HTMLInputElement; 56 | await DebugStorageHelper.setDebugEnabled(target.checked); 57 | 58 | dialogElement.querySelectorAll(".debug-tool")?.forEach((element) => { 59 | element.setAttribute("style", `display: ${target.checked ? "block" : "none"}`); 60 | }); 61 | }); 62 | 63 | dialogElement.querySelector("#modal-settings-sync-fetch")?.addEventListener("click", () => { 64 | SyncWorkspaceStorage.debug_getSyncData(); 65 | }); 66 | dialogElement.querySelector("#modal-settings-sync-push")?.addEventListener("click", () => { 67 | 68 | }); 69 | dialogElement.querySelector("#modal-settings-sync-delete")?.addEventListener("click", () => { 70 | 71 | }); 72 | 73 | dialogElement.querySelector("#modal-settings-export")?.addEventListener("click", () => { 74 | PageSettings.exportSettings(); 75 | }); 76 | dialogElement.querySelector("#modal-settings-import")?.addEventListener("click", () => { 77 | PageSettings.importSettings(); 78 | }); 79 | 80 | dialogElement.querySelector("#modal-settings-close")?.addEventListener("click", () => { 81 | PageSettings.cancelCloseDialog(dialogElement); 82 | }); 83 | dialogElement.addEventListener("cancel", () => { 84 | PageSettings.cancelCloseDialog(dialogElement); 85 | }); 86 | 87 | document.body.appendChild(dialogElement); 88 | document.querySelector("dialog")?.showModal(); 89 | } 90 | 91 | /** 92 | * Exports the settings to a JSON file. 93 | * Just a raw dump of the storage json data. 94 | */ 95 | private static async exportSettings(): Promise { 96 | const data = await StorageHelper.getRawWorkspaces(); 97 | 98 | const toExport = { 99 | "version": VERSION, 100 | data 101 | }; 102 | 103 | console.info("Exporting settings"); 104 | 105 | const settingsBlob = new Blob([JSON.stringify(toExport)], {type: "application/json"}); 106 | const settingsURL = URL.createObjectURL(settingsBlob); 107 | 108 | const a = document.createElement("a"); 109 | a.href = settingsURL; 110 | a.download = Constants.DOWNLOAD_FILENAME; 111 | a.click(); 112 | } 113 | 114 | /** 115 | * Prompt the user to import settings from a JSON file. 116 | * This method opens a file input dialog and reads the file contents. 117 | */ 118 | private static async importSettings(): Promise { 119 | const input = document.createElement("input"); 120 | input.type = "file"; 121 | input.accept = ".json"; 122 | input.style.display = "none"; 123 | input.addEventListener("change", async () => { 124 | if (!input.files || input.files.length === 0) { 125 | return; 126 | } 127 | 128 | const file = input.files[0]; 129 | const reader = new FileReader(); 130 | console.debug("Importing file", file); 131 | reader.onload = async () => { 132 | try { 133 | const data = JSON.parse(reader.result as string); 134 | // if (data.version !== VERSION) { 135 | // throw new Error("Version mismatch"); 136 | // } 137 | 138 | // Prompt the user to confirm if they are sure they want to import data, as it will overwrite all existing data. 139 | const forSure = confirm("Importing settings will overwrite all existing settings. Are you sure you want to continue?"); 140 | if (!forSure) { 141 | LogHelper.successAlert("Import cancelled"); 142 | return; 143 | } 144 | 145 | const parsed = await StorageHelper.workspacesFromJson(data as MessageResponse); 146 | await StorageHelper.setWorkspaces(parsed); 147 | 148 | LogHelper.successAlert("Settings imported successfully, reloading..."); 149 | window.location.reload(); 150 | } catch (e) { 151 | LogHelper.errorAlert("Error importing settings", e); 152 | } 153 | }; 154 | reader.readAsText(file); 155 | }); 156 | 157 | document.body.appendChild(input); 158 | input.click(); 159 | document.body.removeChild(input); 160 | } 161 | } -------------------------------------------------------------------------------- /src/popup-actions.ts: -------------------------------------------------------------------------------- 1 | import { LogHelper } from "./log-helper"; 2 | import { PopupMessageHelper } from "./messages/popup-message-helper"; 3 | import { Workspace } from './obj/workspace'; 4 | import { MessageResponse, MessageResponses } from "./constants/message-responses"; 5 | import { WorkspaceEntryLogic } from "./workspace-entry-logic"; 6 | import { StorageHelper } from "./storage-helper"; 7 | import { TabUtils } from "./utils/tab-utils"; 8 | import { getWorkspaceStorage } from "./popup"; 9 | import { Utils } from "./utils"; 10 | import { FeatureDetect } from "./utils/feature-detect"; 11 | 12 | /** 13 | * Actions that can be performed by the popup. 14 | */ 15 | export class PopupActions { 16 | 17 | public static async addNewWorkspaceFromWindow(workspaceName: string, windowId: number): Promise { 18 | console.log("New workspace from window"); 19 | const response = await PopupMessageHelper.sendAddNewWorkspaceFromWindow(workspaceName, windowId); 20 | this.handleNewWorkspaceResponse(response, windowId); 21 | } 22 | 23 | public static async addNewWorkspace(workspaceName: string, windowId: number): Promise { 24 | const response = await PopupMessageHelper.sendAddNewWorkspace(workspaceName, windowId); 25 | this.handleNewWorkspaceResponse(response, windowId); 26 | } 27 | 28 | private static async handleNewWorkspaceResponse(response: MessageResponse, windowId: number): Promise { 29 | if (response.message === MessageResponses.SUCCESS.message) { 30 | console.debug("Workspace added successfully, refreshing list"); 31 | WorkspaceEntryLogic.listWorkspaces(await getWorkspaceStorage(), await Utils.getAllWindowIds()); 32 | } 33 | else { 34 | LogHelper.errorAlert("Workspace could not be added\n" + response.message); 35 | // Close the window 36 | chrome.windows.remove(windowId); 37 | } 38 | } 39 | 40 | /** 41 | * Open the provided workspace in a new window. 42 | * 43 | *
    44 | *
  1. Send a message to the background script requesting the workspace data
  2. 45 | *
  3. Create a new window with workspace's tabs in the correct order
  4. 46 | *
  5. Tell the background script to update the workspace with the new windowId
  6. 47 | *
48 | * @param workspaceToOpen - 49 | */ 50 | public static async openWorkspace(workspaceToOpen: Workspace): Promise { 51 | if (!workspaceToOpen) { 52 | console.error("Workspace is invalid!", "workspace:", workspaceToOpen); 53 | LogHelper.errorAlert("Error opening workspace. Check the console for more details."); 54 | return; 55 | } 56 | 57 | // Creating the window before we add tabs to it seems like it is messing up the active tab. 58 | // But we don't have to create the window first, as I originally thought. 59 | // So we will create the window after we have the tabs ready to go. 60 | const response = await PopupMessageHelper.sendGetWorkspace(workspaceToOpen.uuid); 61 | if (!response || response.message === MessageResponses.UNKNOWN_MSG.message) { 62 | console.error("Response returned invalid!", "response:", response); 63 | LogHelper.errorAlert("Error opening workspace. Check the console for more details."); 64 | return; 65 | } 66 | 67 | const workspace = Workspace.deserialize(response.data); 68 | 69 | // ------------- 70 | // Check if workspace.windowId is an existing window 71 | const existingWindow = await Utils.getWindowById(workspace.windowId); 72 | 73 | if (existingWindow && existingWindow.id) { 74 | console.debug(`Workspace '${ workspace.name }' is already open in window ${ existingWindow.id }. Focusing...`); 75 | 76 | await Utils.focusWindow(existingWindow.id); 77 | return; 78 | } 79 | // ------------- 80 | 81 | // Then we will open the tabs in the new window 82 | chrome.windows.create({ 83 | focused: true, 84 | url: workspace.getTabs().map(tab => tab.url) 85 | }).then(async newWindow => { 86 | if (!newWindow?.id) { 87 | console.error("New window id is invalid!", "newWindow:", newWindow); 88 | LogHelper.errorAlert("Error opening workspace window. Check the console for more details."); 89 | return; 90 | } 91 | 92 | // The window should be created with the tabs in the correct order, 93 | // but now we need to update the newly created tabs to match the workspace tabs extra 94 | // data (active, pinned, etc). 95 | workspace.windowId = newWindow.id; 96 | await TabUtils.updateTabStubIdsFromTabs(workspace.getTabs(), newWindow.tabs as chrome.tabs.Tab[]); 97 | await TabUtils.updateNewWindowTabsFromTabStubs(workspace.getTabs()); 98 | if (FeatureDetect.supportsTabGroups()) { 99 | await this.groupTabs(workspace); 100 | } 101 | 102 | // Update the workspace with the new windowId in storage 103 | const response = await PopupMessageHelper.sendOpenWorkspace(workspace.uuid, newWindow.id); 104 | 105 | if (!response || response.message === MessageResponses.UNKNOWN_MSG.message) { 106 | console.error("Response returned invalid!", "response:", response); 107 | LogHelper.errorAlert("Your changes might not be saved. Check the console for more details."); 108 | return; 109 | } 110 | // We don't need to do anything with the response, since all the data should now be in sync 111 | // Update the workspace list 112 | WorkspaceEntryLogic.listWorkspaces(await getWorkspaceStorage(), await Utils.getAllWindowIds()); 113 | 114 | }); 115 | } 116 | 117 | /** 118 | * Group the tabs in the workspace. 119 | * 120 | * At this point the window is created with tabs in the correct order. 121 | * We need to create the tab groups with the correct names and colors, and ensure tabs are in their associated groups. 122 | * But there is not a `tabGroups.create` method, so we need to create the tab groups with `chrome.tabs.group` initially, 123 | * then update the tab groups with the correct names and colors. 124 | * 125 | * The Tab objects have a groupId property that is used to associate them with a group from `Workspace.tabGroups`. 126 | * The groupId is the auto-generated ID from the last time the window was open, so we need to update the Tab objects with the new group IDs 127 | * once the new groups are created. 128 | * 129 | * Following that, we need to update the tab groups with the new group IDs. We can probably just clear the tab groups and re-save them. 130 | * 131 | * @param workspace - 132 | */ 133 | public static async groupTabs(workspace: Workspace): Promise { 134 | const tabGroups = workspace.getTabGroups(); 135 | 136 | for (const tabGroupStub of tabGroups) { 137 | const tabIds = workspace.getTabs().filter(tab => tab.groupId === tabGroupStub.id).map(tab => tab.id); 138 | 139 | if (tabIds.length > 0) { 140 | const groupId = await chrome.tabs.group({ 141 | tabIds: tabIds, 142 | createProperties: { 143 | windowId: workspace.windowId 144 | } 145 | }); 146 | console.debug(`Grouped tabs ${ tabIds } into group ${ groupId }`); 147 | 148 | await chrome.tabGroups.update(groupId, { 149 | title: tabGroupStub.title, 150 | color: tabGroupStub.color as chrome.tabGroups.ColorEnum, 151 | collapsed: tabGroupStub.collapsed 152 | }); 153 | // Update the groupId for all tabs in this group 154 | workspace.getTabs().forEach(tab => { 155 | if (tab.groupId === tabGroupStub.id) { 156 | tab.groupId = groupId; 157 | } 158 | }); 159 | // Update the TabGroupStub with the new groupId 160 | tabGroupStub.id = groupId; 161 | } 162 | } 163 | // Clear the existing tab groups and re-save them with the new group IDs 164 | workspace.setTabGroups(tabGroups); 165 | await StorageHelper.setWorkspace(workspace); 166 | } 167 | 168 | 169 | /** 170 | * Called when the clear workspace button is clicked. 171 | * 172 | * Send a message to the background script to clear the workspace data. 173 | * Then update the workspace list. 174 | */ 175 | public static clearWorkspaceData(): void { 176 | PopupMessageHelper.sendClearWorkspaces().then(async response => { 177 | if (response.message === MessageResponses.SUCCESS.message) { 178 | LogHelper.successAlert("Workspace data cleared."); 179 | WorkspaceEntryLogic.listWorkspaces(await StorageHelper.getWorkspaces(), await Utils.getAllWindowIds()); 180 | } 181 | else { 182 | LogHelper.errorAlert("Error clearing workspace data. Check the console for more details."); 183 | } 184 | }); 185 | } 186 | 187 | /** 188 | * Called when the delete workspace button is clicked. 189 | * 190 | * Send a message to the background script to delete the workspace. 191 | * Then update the workspace list. 192 | * @param workspace - 193 | */ 194 | public static deleteWorkspace(workspace: Workspace): void { 195 | PopupMessageHelper.sendDeleteWorkspace(workspace.uuid).then(async response => { 196 | if (response.message === MessageResponses.SUCCESS.message) { 197 | console.log("Workspace deleted", workspace); 198 | WorkspaceEntryLogic.listWorkspaces(await StorageHelper.getWorkspaces(), await Utils.getAllWindowIds()); 199 | } 200 | else { 201 | LogHelper.errorAlert("Error deleting workspace. Check the console for more details."); 202 | } 203 | }); 204 | } 205 | 206 | /** 207 | * Send a message to the background script to rename the workspace. The user has already entered the new name. 208 | * 209 | * 2. Send a message to the background script to rename the workspace. 210 | * 3. Update the workspace list. 211 | * @param workspace - 212 | */ 213 | public static renameWorkspace(workspace: Workspace, newName: string): void { 214 | PopupMessageHelper.sendRenameWorkspace(workspace.uuid, newName).then(async response => { 215 | if (response.message === MessageResponses.SUCCESS.message) { 216 | console.log("Workspace renamed", workspace); 217 | WorkspaceEntryLogic.listWorkspaces(await StorageHelper.getWorkspaces(), await Utils.getAllWindowIds()); 218 | } 219 | else { 220 | LogHelper.errorAlert("Error renaming workspace. Check the console for more details."); 221 | } 222 | }); 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /src/popup.css: -------------------------------------------------------------------------------- 1 | /* normalize css starts here */ 2 | *, 3 | *::before, 4 | *::after { 5 | margin: 0; 6 | padding: 0; 7 | box-sizing: border-box; 8 | } 9 | 10 | /* normalize css ends here */ 11 | 12 | :root { 13 | /* Colors */ 14 | /* Green = should be overridden */ 15 | --primary-color: #757575; 16 | --primary-variant-color: #00ff00; 17 | --secondary-color: #00ff00; 18 | --surface-color: #00ff00; 19 | --surface-container-color: #00ff00; 20 | --error-color: #00ff00; 21 | --on-primary-color: #00ff00; 22 | --on-secondary-color: #00ff00; 23 | --on-surface-color: #00ff00; 24 | --on-surface-variant-color: #00ff00; 25 | --on-error-color: #00ff00; 26 | 27 | /* Dimensions */ 28 | --item-padding: 5px; 29 | --item-margin: 5px; 30 | --subitem-margin: 10px; 31 | } 32 | 33 | @media (prefers-color-scheme: dark) { 34 | :root { 35 | /* --primary-color: #00ff00; */ 36 | --secondary-color: #00ff00; 37 | --surface-color: #333333; 38 | --surface-container-color: #414141; 39 | --error-color: #cf6679; 40 | --on-error-color: #000000; 41 | --on-primary-color: #00ff00; 42 | --on-secondary-color: #00ff00; 43 | --on-surface-color: #ffffff; 44 | --on-surface-variant-color: #b3b3b3; 45 | --on-error-color: #00ff00; 46 | } 47 | } 48 | 49 | @media (prefers-color-scheme: light) { 50 | :root { 51 | /* --primary-color: #000000; */ 52 | --secondary-color: #00ff00; 53 | --surface-color: #ffffff; 54 | --surface-container-color: #b8b6b6; 55 | --error-color: #b00020; 56 | --on-error-color: #000000; 57 | --on-primary-color: #00ff00; 58 | --on-secondary-color: #00ff00; 59 | --on-surface-color: #000000; 60 | --on-surface-variant-color: #363636; 61 | --on-error-color: #ffffff; 62 | } 63 | } 64 | 65 | body { 66 | width: 350px; 67 | min-height: 150px; 68 | /* height: 300px; */ 69 | 70 | color: var(--on-surface-color); 71 | background-color: var(--surface-color); 72 | } 73 | 74 | /* 75 | * Make the inputs look more modern 76 | */ 77 | input, button { 78 | background-color: var(--surface-container-color); 79 | color: var(--on-surface-color); 80 | border: none; 81 | padding: 5px 10px; 82 | margin-bottom: 5px; 83 | border-radius: 5px; 84 | cursor: pointer; 85 | align-self: center; 86 | } 87 | /* No margin bottom for checkboxes */ 88 | input[type="checkbox"] { 89 | margin-bottom: 0; 90 | } 91 | 92 | .settings-checkbox { 93 | display: flex; 94 | align-items: center; 95 | margin-bottom: 3px; 96 | } 97 | .settings-checkbox label { 98 | margin-left: 5px; 99 | } 100 | 101 | .sidebar { 102 | /* To override the width in body */ 103 | width: 100%; 104 | } 105 | 106 | .app { 107 | height: 100%; 108 | display: flex; 109 | align-items: center; 110 | justify-content: center; 111 | flex-direction: column; 112 | text-align: center; 113 | padding: 20px; 114 | } 115 | 116 | .title { 117 | font-size: 18px; 118 | font-weight: 600; 119 | margin-bottom: 10px; 120 | } 121 | 122 | .subtitle { 123 | font-size: 12px; 124 | } 125 | 126 | h1 { 127 | font-size: 12px; 128 | font-weight: 700; 129 | margin-bottom: 10px; 130 | 131 | } 132 | 133 | .header-bar { 134 | display: flex; 135 | justify-content: space-between; 136 | /* align-items: left; */ 137 | width: 100%; 138 | margin-bottom: 0px; 139 | 140 | } 141 | 142 | .header-bar-right { 143 | display: flex; 144 | align-items: center; 145 | padding-right: var(--item-padding); 146 | margin-right: var(--item-margin); 147 | } 148 | 149 | .header-bar-right img { 150 | width: 20px; 151 | height: 20px; 152 | margin-right: 10px; 153 | } 154 | 155 | img#addIcon:hover { 156 | cursor: pointer; 157 | fill: white; 158 | /* Added to change the SVG color to white */ 159 | } 160 | 161 | .icon-button { 162 | cursor: pointer; 163 | } 164 | 165 | .icon-button:hover { 166 | color: var(--primary-color); 167 | } 168 | 169 | .divider { 170 | margin: 30px auto 25px; 171 | width: 50px; 172 | border: .5px dashed #000; 173 | opacity: .1; 174 | } 175 | 176 | /* ## Workspaces List Items ## */ 177 | .workspaces { 178 | width: 100%; 179 | display: flex; 180 | flex-direction: column; 181 | align-items: center; 182 | justify-content: space-between; 183 | } 184 | 185 | .workspace-item { 186 | width: 100%; 187 | display: flex; 188 | align-items: center; 189 | justify-content: space-between; 190 | /* padding: 10px 0; */ 191 | /* border-bottom: 1px solid var(--on-background-color); */ 192 | } 193 | 194 | .workspace-item-interior { 195 | width: 100%; 196 | margin: var(--item-margin); 197 | padding: var(--item-padding); 198 | display: inherit; 199 | align-items: inherit; 200 | justify-content: inherit; 201 | border-radius: 5px; 202 | cursor: pointer; 203 | } 204 | 205 | .workspace-item-interior:hover { 206 | background-color: var(--surface-container-color); 207 | } 208 | 209 | .workspace-open { 210 | font-weight: 700; 211 | } 212 | 213 | .workspace-item .workspace-button { 214 | display: inline-block; 215 | background: none; 216 | border: none; 217 | padding: 0; 218 | /* color: var(--primary-color); */ 219 | cursor: pointer; 220 | } 221 | 222 | .workspace-button-tabs { 223 | margin: 0px 0px var(--subitem-margin) var(--subitem-margin); 224 | color: var(--on-surface-variant-color); 225 | } 226 | 227 | .workspace-item-right { 228 | display: flex; 229 | /* align-items: right; */ 230 | cursor: default; 231 | } 232 | 233 | .modal { 234 | top: 50%; 235 | left: 50%; 236 | background-color: var(--surface-color); 237 | color: var(--on-surface-color); 238 | -webkit-transform: translateX(-50%) translateY(-50%); 239 | -moz-transform: translateX(-50%) translateY(-50%); 240 | -ms-transform: translateX(-50%) translateY(-50%); 241 | transform: translateX(-50%) translateY(-50%); 242 | /* TRBL */ 243 | padding: 5px; 244 | border: 0px; 245 | } 246 | 247 | .modal h2 { 248 | margin-bottom: 2px; 249 | } 250 | .modal h3 { 251 | margin-top: 2px; 252 | } 253 | 254 | .modal #modal-settings-close { 255 | width: 100%; 256 | } 257 | 258 | .modal .modal-settings-bottom { 259 | display: flex; 260 | justify-content: space-between; 261 | margin-top: 3%; 262 | } 263 | 264 | #modal-add-workspace { 265 | display: flex; 266 | flex-wrap: wrap; 267 | flex-direction: column; 268 | align-content: center; 269 | align-items: center; 270 | justify-content: center; 271 | 272 | } 273 | 274 | #modal-input-name { 275 | margin-top: 3px; 276 | } 277 | 278 | ::backdrop { 279 | background-image: linear-gradient(45deg, 280 | rebeccapurple, 281 | rebeccapurple, 282 | rebeccapurple, 283 | rebeccapurple); 284 | opacity: 0.75; 285 | } -------------------------------------------------------------------------------- /src/popup.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import { PopupMessageHelper } from "./messages/popup-message-helper"; 4 | import { PageAddWorkspace } from "./pages/page-add-workspace"; 5 | import { PageSettings } from "./pages/page-settings"; 6 | import "./popup.css"; 7 | import { StorageHelper } from "./storage-helper"; 8 | import { Utils } from "./utils"; 9 | import { WorkspaceEntryLogic } from "./workspace-entry-logic"; 10 | import { WorkspaceStorage } from "./workspace-storage"; 11 | 12 | /** 13 | * This function is called when the popup is opened. 14 | * Setup the listeners for the buttons 15 | */ 16 | async function documentLoaded() { 17 | chrome.windows.onRemoved.addListener(windowRemoved); 18 | const workspaceStorage = await getWorkspaceStorage(); 19 | 20 | document.getElementById("addWorkspace").addEventListener("click", addWorkspaceButtonClicked); 21 | document.getElementById("settings-button").addEventListener("click", settingsButtonClicked); 22 | 23 | WorkspaceEntryLogic.listWorkspaces(workspaceStorage, await Utils.getAllWindowIds()); 24 | } 25 | 26 | /** 27 | * Present a popup asking for the workspace name, then create a new window and add it to the workspaces. 28 | * @returns {Promise} 29 | */ 30 | async function addWorkspaceButtonClicked() { 31 | const pageAddWorkspace = new PageAddWorkspace(); 32 | pageAddWorkspace.open(); 33 | } 34 | 35 | /** 36 | * Present a popup asking for confirmation, then clear all workspace data. 37 | */ 38 | async function settingsButtonClicked() { 39 | const pageSettings = new PageSettings(); 40 | pageSettings.open(); 41 | } 42 | 43 | /** 44 | * When a window is added or removed, update the workspace list. 45 | * @param {chrome.windows.window} window 46 | */ 47 | async function windowRemoved(window) { 48 | console.debug("Popup: windowRemoved", window); 49 | WorkspaceEntryLogic.listWorkspaces(await getWorkspaceStorage()); 50 | } 51 | 52 | /** 53 | * Get the full workspace storage object from the background script 54 | * @returns {Promise} 55 | */ 56 | export async function getWorkspaceStorage() { 57 | return StorageHelper.workspacesFromJson(await PopupMessageHelper.sendGetWorkspaces()); 58 | } 59 | 60 | /** 61 | * This is the entry point for the popup. 62 | * When the DOM is loaded, call the documentLoaded function, to keep things clean. 63 | */ 64 | (async function () { document.addEventListener("DOMContentLoaded", documentLoaded); })(); 65 | -------------------------------------------------------------------------------- /src/storage/bookmark-storage-helper.ts: -------------------------------------------------------------------------------- 1 | import { Constants } from "../constants/constants"; 2 | import { Workspace } from "../obj/workspace"; 3 | import { StorageHelper } from "../storage-helper"; 4 | 5 | export class BookmarkStorageHelper { 6 | /** 7 | * Retrieves the extension bookmark folder. If the folder does not exist, it creates one. 8 | * 9 | * @returns A promise that resolves to the bookmark folder node, or undefined if it cannot be found or created. 10 | */ 11 | public static async getExtensionBookmarkFolder(): Promise { 12 | if (!await this.isBookmarkSaveEnabled()) { 13 | return undefined; 14 | } 15 | 16 | const otherBookmarksFolder = await this.getOtherBookmarksFolder(); 17 | const bookmarkFolder = otherBookmarksFolder?.children?.find((node) => node.title === Constants.BOOKMARKS_FOLDER_NAME); 18 | if (bookmarkFolder === undefined) { 19 | return await this.createExtensionBookmarkFolder(otherBookmarksFolder); 20 | } 21 | return bookmarkFolder; 22 | } 23 | 24 | private static async createExtensionBookmarkFolder(otherBookmarksFolder: chrome.bookmarks.BookmarkTreeNode | undefined): Promise { 25 | if (otherBookmarksFolder === undefined) { 26 | console.error("Could not find the 'Other bookmarks' folder."); 27 | return Promise.reject("Could not find the 'Other bookmarks' folder."); 28 | } 29 | return await chrome.bookmarks.create({ parentId: otherBookmarksFolder.id, title: Constants.BOOKMARKS_FOLDER_NAME }); 30 | } 31 | 32 | /** 33 | * Retrieves the "Other Bookmarks" folder from the Chrome bookmarks tree. 34 | * 35 | * @returns A promise that resolves to the "Other Bookmarks" folder node if found, otherwise undefined. 36 | */ 37 | private static async getOtherBookmarksFolder(): Promise { 38 | const bookmarks = await chrome.bookmarks.getTree(); 39 | if (bookmarks.length === 0) { 40 | return undefined; 41 | } 42 | // We can't find the "Other bookmarks" folder by name, since it's localized, so we'll just grab the second child 43 | return bookmarks[0].children?.at(1); 44 | } 45 | 46 | /** 47 | * Save the workspace to bookmarks. 48 | * 49 | * Ensures the associated workspace folder is fully replaced with the new workspace tabs. 50 | * 51 | * We are going to go with the constraint that names should be unique, and if they aren't 52 | * one of them will be overwritten. 53 | * Unique names aren't enforced by the extension, but hopefully people don't name things the same since it's confusing. 54 | */ 55 | public static async saveWorkspace(workspace: Workspace, bookmarkFolder?: chrome.bookmarks.BookmarkTreeNode | undefined): Promise { 56 | if (!await this.isBookmarkSaveEnabled()) { 57 | console.debug("Bookmark saving is disabled, skipping."); 58 | return; 59 | } 60 | console.debug(`Saving workspace ${ workspace.name } to bookmarks...`); 61 | 62 | // Resolve the bookmark folder using the provided one or our default 63 | let resolvedBookmarkFolder; 64 | if (bookmarkFolder === undefined) { 65 | resolvedBookmarkFolder = await this.getExtensionBookmarkFolder(); 66 | } 67 | else { 68 | resolvedBookmarkFolder = await bookmarkFolder; 69 | } 70 | 71 | if (resolvedBookmarkFolder === undefined) { 72 | console.error("Could not find the bookmark folder, cannot save workspace to bookmarks."); 73 | return Promise.reject("Could not find the bookmark folder."); 74 | } 75 | const workspaceFolders = resolvedBookmarkFolder.children?.filter((node) => node.title === workspace.name); 76 | // Delete the workspace folder and all children so we can recreate it with the new tabs 77 | if (workspaceFolders !== undefined) { 78 | if (workspaceFolders.length > 1) { 79 | console.warn(`Found multiple workspace folders with the name '${ workspace.name }'. All will be removed!`); 80 | } 81 | workspaceFolders.forEach(async (workspaceFolder) => { 82 | await chrome.bookmarks.removeTree(workspaceFolder.id); 83 | }); 84 | } 85 | 86 | // Create the workspace folder 87 | const newWorkspaceFolder = await chrome.bookmarks.create({ parentId: resolvedBookmarkFolder.id, title: workspace.name }); 88 | 89 | // Add all the tabs to the workspace folder 90 | for (const tab of workspace.getTabs()) { 91 | await chrome.bookmarks.create({ parentId: newWorkspaceFolder.id, title: tab.title, url: tab.url }); 92 | } 93 | } 94 | 95 | /** 96 | * Remove the workspace from bookmarks. 97 | * @param workspace - The workspace to remove. 98 | */ 99 | public static async removeWorkspace(workspace: Workspace, bookmarkFolder = this.getExtensionBookmarkFolder()): Promise { 100 | if (!await this.isBookmarkSaveEnabled()) { 101 | console.debug("Bookmark saving is disabled, skipping."); 102 | return; 103 | } 104 | 105 | const resolvedBookmarkFolder = await bookmarkFolder; 106 | if (resolvedBookmarkFolder === undefined) { 107 | console.error("Could not find the bookmark folder, cannot save workspace to bookmarks."); 108 | return Promise.reject("Could not find the bookmark folder."); 109 | } 110 | const workspaceFolder = resolvedBookmarkFolder.children?.find((node) => node.title === workspace.name); 111 | 112 | if (workspaceFolder !== undefined) { 113 | await chrome.bookmarks.removeTree(workspaceFolder.id); 114 | } 115 | } 116 | 117 | /** 118 | * Check if the user has enabled saving bookmarks. 119 | */ 120 | public static async isBookmarkSaveEnabled(): Promise { 121 | const value = await StorageHelper.getValue(Constants.STORAGE_KEYS.settings.saveBookmarks, "true"); 122 | return value === "true"; 123 | } 124 | 125 | /** 126 | * Set the user's preference for saving bookmarks. 127 | * @param value - The new value for the setting. 128 | */ 129 | public static async setBookmarkSaveEnabled(value: boolean): Promise { 130 | await StorageHelper.setValue(Constants.STORAGE_KEYS.settings.saveBookmarks, value.toString()); 131 | } 132 | } -------------------------------------------------------------------------------- /src/storage/debug-storage-helpet.ts: -------------------------------------------------------------------------------- 1 | import { Constants } from "../constants/constants"; 2 | import { StorageHelper } from "../storage-helper"; 3 | 4 | export class DebugStorageHelper { 5 | /** 6 | * Check if the user has enabled debug mode. 7 | * 8 | * Defaults to false. 9 | */ 10 | public static async isDebugEnabled(): Promise { 11 | const value = await StorageHelper.getValue(Constants.STORAGE_KEYS.settings.debug, "false"); 12 | return value === "true"; 13 | } 14 | 15 | /** 16 | * Set the user's preference for debug mode. 17 | * @param value - The new value for the setting. 18 | */ 19 | public static async setDebugEnabled(value: boolean): Promise { 20 | await StorageHelper.setValue(Constants.STORAGE_KEYS.settings.debug, value.toString()); 21 | } 22 | } -------------------------------------------------------------------------------- /src/templates/dialogAddWorkspaceTemplate.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | 9 | -------------------------------------------------------------------------------- /src/templates/dialogPopupTemplate.html: -------------------------------------------------------------------------------- 1 | 2 |

${prompt}

3 | 7 |
-------------------------------------------------------------------------------- /src/templates/dialogSettingsTemplate.html: -------------------------------------------------------------------------------- 1 | 2 |

Extension Settings (v${version})

3 |
4 | 5 | 6 |
7 |
8 | 9 | 10 |
11 |
12 | 13 | 14 |
15 |
16 |

Cloud Data

17 | 18 | 19 | 20 |
21 |

Local Data

22 | 23 | 24 | 25 | 28 |
-------------------------------------------------------------------------------- /src/templates/workspaceElemTemplate.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
${workspaceName}${tabsCount} tabs
4 |
5 | 6 | edit 7 | delete 8 |
9 |
10 |
-------------------------------------------------------------------------------- /src/test/data/sync-storage-data-1.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspace_metadata_cac9dc5b-178a-42b8-b50f-aa0c17c370f2": { 3 | "lastUpdated": 1731541866684, 4 | "name": "asdf", 5 | "numTabChunks": 1, 6 | "uuid": "cac9dc5b-178a-42b8-b50f-aa0c17c370f2", 7 | "windowId": 1310797554 8 | }, 9 | "workspace_metadata_e36de816-4071-4a48-8ab2-03719ff4b301": { 10 | "lastUpdated": 1731541342068, 11 | "name": "test", 12 | "numTabChunks": 1, 13 | "uuid": "e36de816-4071-4a48-8ab2-03719ff4b301", 14 | "windowId": 1310797546 15 | }, 16 | "workspace_tab_groups_cac9dc5b-178a-42b8-b50f-aa0c17c370f2": [], 17 | "workspace_tab_groups_e36de816-4071-4a48-8ab2-03719ff4b301": [], 18 | "workspace_tabs_cac9dc5b-178a-42b8-b50f-aa0c17c370f2_0": [ 19 | "{\"id\":1310797555,\"index\":0,\"title\":\"Google\",\"url\":\"https://www.google.com/\",\"favIconUrl\":\"https://www.google.com/favicon.ico\",\"pinned\":false,\"windowId\":1310797554,\"active\":false,\"groupId\":-1,\"mutedInfo\":{\"extensionId\":\"ognnkoophhofbamdhhgbcaglbmpjcpak\",\"muted\":false,\"reason\":\"extension\"}}", 20 | "{\"id\":1310797556,\"index\":1,\"title\":\"Home | Archive of Our Own\",\"url\":\"https://archiveofourown.org/\",\"favIconUrl\":\"https://archiveofourown.org/favicon.ico\",\"pinned\":false,\"windowId\":1310797554,\"active\":true,\"groupId\":-1,\"mutedInfo\":{\"extensionId\":\"ognnkoophhofbamdhhgbcaglbmpjcpak\",\"muted\":false,\"reason\":\"extension\"}}" 21 | ], 22 | "workspace_tabs_e36de816-4071-4a48-8ab2-03719ff4b301_0": [ 23 | "{\"id\":1310797547,\"index\":0,\"title\":\"Google\",\"url\":\"https://www.google.com/\",\"favIconUrl\":\"https://www.google.com/favicon.ico\",\"pinned\":false,\"windowId\":1310797540,\"active\":false,\"groupId\":-1,\"mutedInfo\":{\"extensionId\":\"ognnkoophhofbamdhhgbcaglbmpjcpak\",\"muted\":false,\"reason\":\"extension\"}}", 24 | "{\"id\":1310797548,\"index\":1,\"title\":\"Bing\",\"url\":\"https://www.bing.com/\",\"favIconUrl\":\"https://www.bing.com/sa/simg/favicon-trans-bg-blue-mg-png.png\",\"pinned\":false,\"windowId\":1310797540,\"active\":true,\"groupId\":-1,\"mutedInfo\":{\"extensionId\":\"ognnkoophhofbamdhhgbcaglbmpjcpak\",\"muted\":false,\"reason\":\"extension\"}}" 25 | ] 26 | } -------------------------------------------------------------------------------- /src/test/e2e/basic-workflow.test.ts: -------------------------------------------------------------------------------- 1 | import { E2ECommon } from "./utils/e2e-common"; 2 | 3 | let common: E2ECommon; 4 | 5 | jest.setTimeout(30 * 1000); 6 | 7 | beforeEach(async () => { 8 | common = new E2ECommon(); 9 | await common.beforeEach(); 10 | }); 11 | 12 | // Note: this is how you can get console logs from the page. 13 | // page.on('console', msg => console.log('PAGE LOG:', msg.text())); 14 | 15 | afterEach(async () => { 16 | await common.afterEach(); 17 | }); 18 | 19 | test("creating a new workspace adds it to the list", async () => { 20 | // Setup listener for the new window (browser) popup 21 | const newWindowPagePromise = common.getNewWindowPagePromise(); 22 | const page = common.page; 23 | 24 | // Click the button to add a workspace 25 | let btn = await page.waitForSelector("#addWorkspace"); 26 | expect(await btn?.isVisible()).toBe(true); 27 | await btn?.click(); 28 | // Verify the dialog is visible 29 | let dialog = await page.waitForSelector("dialog"); 30 | expect(await dialog?.isVisible()).toBe(true); 31 | 32 | // Click the button to add a new workspace 33 | btn = await page.waitForSelector("#modal-new-workspace"); 34 | expect(await btn?.isVisible()).toBe(true); 35 | await btn?.click(); 36 | 37 | // Verify the dialog is visible 38 | dialog = await page.waitForSelector("dialog"); 39 | expect(await dialog?.isVisible()).toBe(true); 40 | 41 | // Enter the name of the new workspace 42 | await page.type("#modal-input-name", "test workspace"); 43 | 44 | // Click the submit input 45 | await page.click("#modal-submit"); 46 | 47 | // Wait for the new window to open 48 | const newPage = await newWindowPagePromise; 49 | expect(newPage).toBeDefined(); 50 | 51 | // Navigate to google.com 52 | await newPage?.goto("https://www.google.com", { waitUntil: 'domcontentloaded' }); 53 | 54 | // Close the new window 55 | await newPage?.close(); 56 | 57 | // Verify there is a new workspace in the list, and that it has the correct name 58 | const list = await page.waitForSelector("#workspaces-list"); 59 | expect(await list?.isVisible()).toBe(true); 60 | 61 | // Use ElementHandler.waitForSelector to wait for the new workspace text to appear 62 | const workspaceBtn = await list?.waitForSelector("xpath///div[contains(text(), 'test workspace')]"); 63 | expect(await workspaceBtn?.evaluate((el) => el.textContent)).toContain("1 tabs"); 64 | }); 65 | 66 | test("clicking a workspace opens it", async () => { 67 | // Setup listener for the new window (browser) popup 68 | const newWindowPagePromise = common.getNewWindowPagePromise(); 69 | const page = common.page; 70 | 71 | // Add a workspace 72 | let btn = await page.waitForSelector("#addWorkspace"); 73 | expect(await btn?.isVisible()).toBe(true); 74 | await btn?.click(); 75 | 76 | 77 | // Verify the dialog is visible 78 | let dialog = await page.waitForSelector("dialog"); 79 | expect(await dialog?.isVisible()).toBe(true); 80 | 81 | 82 | // Click the button to add a new workspace 83 | btn = await page.waitForSelector("#modal-new-workspace"); 84 | expect(await btn?.isVisible()).toBe(true); 85 | await btn?.click(); 86 | 87 | // Verify the dialog is visible 88 | dialog = await page.waitForSelector("dialog"); 89 | expect(await dialog?.isVisible()).toBe(true); 90 | 91 | // Enter the name of the new workspace 92 | await page.type("#modal-input-name", "test workspace"); 93 | 94 | // Click the submit input 95 | await page.click("#modal-submit"); 96 | 97 | // Wait for the new window to open 98 | let newPage = await newWindowPagePromise; 99 | expect(newPage).toBeDefined(); 100 | 101 | // Navigate to google.com 102 | await newPage?.goto("https://www.google.com", { waitUntil: 'domcontentloaded' }); 103 | 104 | // Close the new window 105 | await newPage?.close(); 106 | 107 | // Verify there is a new workspace in the list, and that it has the correct name 108 | const list = await page.waitForSelector("#workspaces-list"); 109 | expect(await list?.isVisible()).toBe(true); 110 | 111 | // Use ElementHandler.waitForSelector to wait for the new workspace text to appear 112 | const workspaceBtn = await list?.waitForSelector("xpath///div[contains(text(), 'test workspace')]"); 113 | // Click the workspace 114 | expect(await workspaceBtn?.isVisible()).toBe(true); 115 | await workspaceBtn?.click(); 116 | 117 | // Wait for the new window to open 118 | newPage = await newWindowPagePromise; 119 | expect(newPage).toBeDefined(); 120 | 121 | // Verify the new page is google.com 122 | expect(await newPage?.url()).toBe("https://www.google.com/"); 123 | }); 124 | 125 | test("creating a new workspace from the current window adds it to the list with existing tabs", async () => { 126 | // This test is similar to the first test, but it uses the "New Workspace from Window" button 127 | // Steps: 128 | // 1. Open a few new tabs to specific URLs 129 | // 2. Switch to the popup page 130 | // 3. Click the '#settings-button' button 131 | // 4. Click the '#modal-new-workspace-from-window' button 132 | // 5. Enter a name for the new workspace in the prompt 133 | // 6. Verify the new workspace is in the list with the correct name and number of tabs 134 | 135 | const page = common.page; 136 | 137 | // Add a few tabs 138 | const urls = ["https://www.google.com", "https://www.bing.com", "https://www.yahoo.com"]; 139 | for (const url of urls) { 140 | const newPage = await common.browser.newPage(); 141 | await newPage.goto(url, { waitUntil: 'domcontentloaded' }); 142 | } 143 | 144 | // Switch to the popup page 145 | await page.bringToFront(); 146 | 147 | // Click the settings button 148 | const settingsBtn = await page.waitForSelector("#addWorkspace"); 149 | expect(await settingsBtn?.isVisible()).toBe(true); 150 | await settingsBtn?.click(); 151 | 152 | // Click the new workspace from window button 153 | const newWorkspaceBtn = await page.waitForSelector("#modal-new-workspace-from-window"); 154 | expect(await newWorkspaceBtn?.isVisible()).toBe(true); 155 | await newWorkspaceBtn?.click(); 156 | 157 | // Enter the name of the new workspace 158 | await page.type("#modal-input-name", "test workspace"); 159 | 160 | // Click the submit input 161 | await page.click("#modal-submit"); 162 | 163 | // Wait for all elements to be gone 164 | await page.waitForSelector("dialog", { hidden: true }); 165 | 166 | // Verify there is a new workspace in the list, and that it has the correct name 167 | const list = await page.waitForSelector("#workspaces-list"); 168 | expect(await list?.isVisible()).toBe(true); 169 | 170 | // Use ElementHandler.waitForSelector to wait for the new workspace text to appear 171 | const workspaceBtn = await list?.waitForSelector("xpath///div[contains(text(), 'test workspace')]"); 172 | expect(await workspaceBtn?.evaluate((el) => el.textContent)).toContain("3 tabs"); 173 | }); -------------------------------------------------------------------------------- /src/test/e2e/rename-workflow.test.ts: -------------------------------------------------------------------------------- 1 | import { E2ECommon } from "./utils/e2e-common"; 2 | 3 | let common: E2ECommon; 4 | 5 | jest.setTimeout(30 * 1000); 6 | 7 | beforeEach(async () => { 8 | common = new E2ECommon(); 9 | await common.beforeEach(); 10 | }); 11 | 12 | // Note: this is how you can get console logs from the page. 13 | // page.on('console', msg => console.log('PAGE LOG:', msg.text())); 14 | 15 | afterEach(async () => { 16 | await common.afterEach(); 17 | }); 18 | 19 | test("creating workspace and renaming it works", async () => { 20 | // Setup listener for the new window (browser) popup 21 | const newWindowPagePromise = common.getNewWindowPagePromise(); 22 | const page = common.page; 23 | 24 | // Click the button to add a workspace 25 | let btn = await page.waitForSelector("#addWorkspace"); 26 | expect(await btn?.isVisible()).toBe(true); 27 | await btn?.click(); 28 | 29 | // Verify the dialog is visible 30 | let dialog = await page.waitForSelector("dialog"); 31 | expect(await dialog?.isVisible()).toBe(true); 32 | 33 | // Click the button to add a new workspace 34 | btn = await page.waitForSelector("#modal-new-workspace"); 35 | expect(await btn?.isVisible()).toBe(true); 36 | await btn?.click(); 37 | 38 | // Verify the dialog is visible 39 | dialog = await page.waitForSelector("dialog"); 40 | expect(await dialog?.isVisible()).toBe(true); 41 | 42 | // Enter the name of the new workspace 43 | await page.type("#modal-input-name", "test workspace"); 44 | 45 | // Click the submit input 46 | await page.click("#modal-submit"); 47 | 48 | // Wait for the new window to open 49 | const newPage = await newWindowPagePromise; 50 | expect(newPage).toBeDefined(); 51 | 52 | // Navigate to google.com 53 | await newPage?.goto("https://www.google.com", { waitUntil: 'domcontentloaded' }); 54 | 55 | // Close the new window 56 | await newPage?.close(); 57 | 58 | // Verify there is a new workspace in the list, and that it has the correct name 59 | const list = await page.waitForSelector("#workspaces-list"); 60 | expect(await list?.isVisible()).toBe(true); 61 | 62 | // Use ElementHandler.waitForSelector to wait for the new workspace text to appear 63 | const workspaceBtn = await list?.waitForSelector("xpath///div[contains(text(), 'test workspace')]"); 64 | expect(await workspaceBtn?.evaluate((el) => el.textContent)).toContain("1 tabs"); 65 | 66 | // Edit the workspace name 67 | const editBtn = await page.waitForSelector("#edit-button"); 68 | expect(await editBtn?.isVisible()).toBe(true); 69 | await editBtn?.click(); 70 | 71 | // Verify the dialog is visible 72 | const renameDialog = await page.waitForSelector("dialog"); 73 | expect(await renameDialog?.isVisible()).toBe(true); 74 | 75 | // Enter the name of the new workspace 76 | await page.type("#modal-input-name", "renamed workspace"); 77 | 78 | // Click the submit input 79 | await page.click("#modal-submit"); 80 | 81 | // Verify the workspace name has been updated 82 | const renamedWorkspaceBtn = await list?.waitForSelector("xpath///div[contains(text(), 'renamed workspace')]"); 83 | expect(await renamedWorkspaceBtn?.evaluate((el) => el.textContent)).toContain("1 tabs"); 84 | }); 85 | -------------------------------------------------------------------------------- /src/test/e2e/utils/e2e-common.ts: -------------------------------------------------------------------------------- 1 | import puppeteer, { Browser, Page } from "puppeteer"; 2 | import { E2EConstants } from "./e2e-constants"; 3 | import assert from "assert"; 4 | 5 | export class E2ECommon { 6 | public browser!: Browser; 7 | public page!: Page; 8 | 9 | /* 10 | // Use this to debug in the browser, with devtools: true: 11 | await page.evaluate(() => { 12 | debugger; 13 | }); 14 | */ 15 | public async beforeEach() { 16 | // https://pptr.dev/guides/debugging 17 | this.browser = await puppeteer.launch({ 18 | // slowMo: 250, // slow down by 250ms 19 | // headless: false, 20 | headless: "new", 21 | args: [ 22 | `--disable-extensions-except=${ E2EConstants.EXTENSION_PATH }`, 23 | `--load-extension=${ E2EConstants.EXTENSION_PATH }` 24 | ], 25 | // devtools: true 26 | }); 27 | 28 | if (!this.browser) { 29 | assert(this.browser); 30 | return; 31 | } 32 | 33 | this.page = await this.browser.newPage(); 34 | 35 | await this.page.goto(`chrome-extension://${ E2EConstants.EXTENSION_ID }/popup.html`); 36 | 37 | // Clear the local and sync storage before each test 38 | await this.page.evaluate(() => { 39 | chrome.storage.local.clear(); 40 | chrome.storage.sync.clear(); 41 | }); 42 | 43 | await this.page.reload(); 44 | 45 | if (!this.page) { 46 | assert(this.page); 47 | return; 48 | } 49 | } 50 | 51 | public async afterEach() { 52 | await this.browser?.close(); 53 | } 54 | 55 | /** 56 | * Get a promise that resolves to a new page when a new window is created. 57 | * @returns A promise that resolves to a new page when a new window is created. 58 | */ 59 | public getNewWindowPagePromise(): Promise { 60 | return new Promise((resolve) => { 61 | this.browser?.on("targetcreated", async (target) => { 62 | const newPage = await target.page(); 63 | if (newPage) { 64 | resolve(newPage); 65 | } 66 | }); 67 | }); 68 | } 69 | } 70 | 71 | // (name: string, fn?: jest.ProvidesCallback | undefined, timeout?: number | undefined) => void 72 | export function ignore(_name: string, _fn?: (() => void) | undefined, _timeout?: number): void { 73 | // Do nothing 74 | } -------------------------------------------------------------------------------- /src/test/e2e/utils/e2e-constants.ts: -------------------------------------------------------------------------------- 1 | 2 | export class E2EConstants { 3 | public static EXTENSION_PATH = `${ process.cwd() }/build/chrome`; 4 | public static EXTENSION_ID = 'feehlkcbifmladjmmpkghfokcngfkkkp'; 5 | } -------------------------------------------------------------------------------- /src/test/jest/mock-extension-apis.js: -------------------------------------------------------------------------------- 1 | global.chrome = { 2 | storage: { 3 | local: { 4 | get: async () => { throw new Error("Unimplemented.") }, 5 | set: async () => { jest.fn() }, 6 | clear: async () => { throw new Error("Unimplemented.") } 7 | }, 8 | sync: { 9 | set: jest.fn(), 10 | get: jest.fn(), 11 | remove: jest.fn(), 12 | QUOTA_BYTES_PER_ITEM: 8192, 13 | MAX_WRITE_OPERATIONS_PER_HOUR: 1800, 14 | MAX_WRITE_OPERATIONS_PER_MINUTE: 120 15 | } 16 | }, 17 | windows: { 18 | onRemoved: { 19 | addListener: () => { jest.fn(); } 20 | }, 21 | onCreated: { 22 | addListener: () => { jest.fn(); } 23 | }, 24 | update: jest.fn(), 25 | get: jest.fn() 26 | }, 27 | tabs: { 28 | onRemoved: { 29 | addListener: () => { jest.fn(); } 30 | }, 31 | onCreated: { 32 | addListener: () => { jest.fn(); } 33 | }, 34 | query: jest.fn(), 35 | get: jest.fn() 36 | }, 37 | tabGroups: { 38 | query: jest.fn() 39 | }, 40 | runtime: { 41 | onMessage: { 42 | addListener: () => { jest.fn(); } 43 | } 44 | }, 45 | action: { 46 | setBadgeText: jest.fn() 47 | } 48 | }; 49 | global.VERSION = "1.0.0"; 50 | -------------------------------------------------------------------------------- /src/test/jest/setup-jest.js: -------------------------------------------------------------------------------- 1 | import {expect} from '@jest/globals'; 2 | // remember to export `toBeWithinRange` as well 3 | // import {toBeWithinRange} from './toBeWithinRange'; 4 | 5 | expect.extend({ 6 | // toBeWithinRange, 7 | }); -------------------------------------------------------------------------------- /src/test/unit/background-message-handlers.test.js: -------------------------------------------------------------------------------- 1 | import { Background } from "../../background"; 2 | import { MessageResponses } from "../../constants/message-responses"; 3 | import { Messages } from "../../constants/messages"; 4 | import { BackgroundMessageHandlers } from "../../messages/background-message-handlers"; 5 | import { StorageHelper } from "../../storage-helper"; 6 | 7 | 8 | // Mock the storage helper, can't be done in beforeEach 9 | jest.mock('../../storage-helper'); 10 | 11 | describe("BackgroundMessageHandlers", () => { 12 | beforeEach(() => { 13 | jest.restoreAllMocks(); 14 | jest.resetAllMocks(); 15 | jest.clearAllMocks(); 16 | 17 | // Mock the listeners 18 | chrome.runtime.onMessage.addListener = jest.fn(); 19 | chrome.windows.onRemoved.addListener = jest.fn(); 20 | chrome.tabs.onRemoved.addListener = jest.fn(); 21 | chrome.tabs.onCreated.addListener = jest.fn(); 22 | }); 23 | 24 | describe('processNewWorkspace', () => { 25 | it('should return FAILURE when addWorkspace fails', async () => { 26 | StorageHelper.addWorkspace.mockResolvedValue(false); 27 | const result = await BackgroundMessageHandlers.processNewWorkspace({ payload: { workspaceName: 'test', windowId: 1 } }); 28 | expect(result).toBe(MessageResponses.ERROR); 29 | }); 30 | 31 | it('should return SUCCESS when addWorkspace succeeds', async () => { 32 | StorageHelper.addWorkspace.mockResolvedValue(true); 33 | const result = await BackgroundMessageHandlers.processNewWorkspace({ payload: { workspaceName: 'test', windowId: 1 } }); 34 | expect(result).toBe(MessageResponses.SUCCESS); 35 | }); 36 | }); 37 | 38 | describe('processNewWorkspaceFromWindow', () => { 39 | beforeEach(() => { 40 | jest.spyOn(Background, 'saveWindowTabsToWorkspace').mockResolvedValue(true); 41 | }); 42 | it('should return SUCCESS when new workspace is created and tabs are saved', async () => { 43 | StorageHelper.addWorkspace.mockResolvedValue(true); 44 | const request = { payload: { workspaceName: 'Test Workspace', windowId: 123 } }; 45 | 46 | const response = await BackgroundMessageHandlers.processNewWorkspaceFromWindow(request); 47 | 48 | expect(response).toBe(MessageResponses.SUCCESS); 49 | expect(Background.saveWindowTabsToWorkspace).toHaveBeenCalledWith(123); 50 | }); 51 | 52 | it('should return ERROR when processNewWorkspace fails', async () => { 53 | StorageHelper.addWorkspace.mockResolvedValue(false); // Simulate failure in creating workspace 54 | const request = { payload: { workspaceName: 'Test Workspace', windowId: 123 } }; 55 | 56 | const response = await BackgroundMessageHandlers.processNewWorkspaceFromWindow(request); 57 | 58 | expect(response).toBe(MessageResponses.ERROR); 59 | expect(Background.saveWindowTabsToWorkspace).not.toHaveBeenCalled(); 60 | }); 61 | }); 62 | 63 | describe('messageListener', () => { 64 | it('should process new workspace and send response when request type is MSG_NEW_WORKSPACE', async () => { 65 | StorageHelper.addWorkspace.mockResolvedValue(true); 66 | const sendResponse = jest.fn(); 67 | const request = { type: Messages.MSG_NEW_WORKSPACE, payload: { workspaceName: 'test', windowId: 1 } }; 68 | 69 | jest.spyOn(BackgroundMessageHandlers, 'processNewWorkspace').mockResolvedValue(MessageResponses.SUCCESS); 70 | const result = BackgroundMessageHandlers.messageListener(request, {}, sendResponse); 71 | 72 | expect(result).toBe(true); 73 | await Promise.resolve(); // wait for promises to resolve 74 | expect(sendResponse).toHaveBeenCalledWith(MessageResponses.SUCCESS); 75 | }); 76 | 77 | it('should log unknown message and send response when request type is unknown', () => { 78 | const sendResponse = jest.fn(); 79 | const request = { type: 'UNKNOWN' }; 80 | const result = BackgroundMessageHandlers.messageListener(request, {}, sendResponse); 81 | 82 | expect(result).toBe(false); 83 | expect(sendResponse).toHaveBeenCalledWith(MessageResponses.UNKNOWN_MSG); 84 | }); 85 | }); 86 | 87 | describe('processOpenWorkspace', () => { 88 | it('should return an error response if uuid or windowId is not provided', async () => { 89 | const request = { 90 | payload: { 91 | data: { 92 | uuid: null, 93 | windowId: 1 94 | } 95 | } 96 | }; 97 | 98 | const response = await BackgroundMessageHandlers.processOpenWorkspace(request); 99 | expect(response).toEqual(MessageResponses.ERROR); 100 | }); 101 | 102 | it('should return an error response if windowId is not provided', async () => { 103 | const request = { 104 | payload: { 105 | data: { 106 | uuid: '123', 107 | windowId: null 108 | } 109 | } 110 | }; 111 | 112 | const response = await BackgroundMessageHandlers.processOpenWorkspace(request); 113 | expect(response).toEqual(MessageResponses.ERROR); 114 | }); 115 | 116 | it('should get the workspace, update it, clear its tabs, and return the seralized data', async () => { 117 | const request = { 118 | payload: { 119 | uuid: '123', 120 | windowId: 1 121 | } 122 | }; 123 | 124 | const mockWorkspace = { 125 | windowId: null, 126 | serialize: jest.fn().mockReturnValue('serialized data'), 127 | clearTabs: jest.fn() 128 | }; 129 | 130 | (StorageHelper.getWorkspace).mockResolvedValue(mockWorkspace); 131 | (StorageHelper.setWorkspace).mockResolvedValue(undefined); 132 | 133 | const response = await BackgroundMessageHandlers.processOpenWorkspace(request); 134 | 135 | expect(StorageHelper.getWorkspace).toHaveBeenCalledWith('123'); 136 | expect(mockWorkspace.windowId).toBe(1); 137 | expect(StorageHelper.setWorkspace).toHaveBeenCalledWith(mockWorkspace); 138 | }); 139 | }); 140 | 141 | describe("processClearWorkspaces", () => { 142 | it('should clear the workspaces, and report success', async () => { 143 | const request = { 144 | payload: { 145 | uuid: '123' 146 | } 147 | }; 148 | 149 | const mockWorkspace = { 150 | clearTabs: jest.fn() 151 | }; 152 | 153 | (StorageHelper.getWorkspace).mockResolvedValue(mockWorkspace); 154 | 155 | const response = await BackgroundMessageHandlers.processClearWorkspaces(request); 156 | 157 | expect(StorageHelper.clearWorkspaces).toHaveBeenCalled(); 158 | expect(response).toEqual(MessageResponses.SUCCESS); 159 | }); 160 | }); 161 | 162 | describe("processDeleteWorkspace", () => { 163 | it('should return an error response if uuid is not provided', async () => { 164 | const request = { 165 | payload: { 166 | uuid: null 167 | } 168 | }; 169 | 170 | const response = await BackgroundMessageHandlers.processDeleteWorkspace(request); 171 | expect(response).toEqual(MessageResponses.ERROR); 172 | }); 173 | 174 | it('should get the workspace, delete it, and report success', async () => { 175 | const request = { 176 | payload: { 177 | uuid: '123' 178 | } 179 | }; 180 | 181 | (StorageHelper.removeWorkspace).mockResolvedValue(true); 182 | 183 | const response = await BackgroundMessageHandlers.processDeleteWorkspace(request); 184 | expect(StorageHelper.removeWorkspace).toHaveBeenCalledWith('123'); 185 | expect(response).toEqual(MessageResponses.SUCCESS); 186 | }); 187 | }); 188 | 189 | describe("processRenameWorkspace", () => { 190 | it('should return an error response if uuid or newName is not provided', async () => { 191 | const request = { 192 | payload: { 193 | uuid: null, 194 | newName: 'test' 195 | } 196 | }; 197 | 198 | const response = await BackgroundMessageHandlers.processRenameWorkspace(request); 199 | expect(response).toEqual(MessageResponses.ERROR); 200 | }); 201 | 202 | it('should get the workspace, update its name, and report success', async () => { 203 | const request = { 204 | payload: { 205 | uuid: '123', 206 | newName: 'test' 207 | } 208 | }; 209 | 210 | (StorageHelper.renameWorkspace).mockResolvedValue(true); 211 | 212 | const response = await BackgroundMessageHandlers.processRenameWorkspace(request); 213 | expect(StorageHelper.renameWorkspace).toHaveBeenCalledWith('123', 'test'); 214 | expect(response).toEqual(MessageResponses.SUCCESS); 215 | }); 216 | }); 217 | 218 | describe("processGetWorkspace", () => { 219 | it('should return an error response if uuid is not provided', async () => { 220 | const request = { 221 | payload: { 222 | uuid: null 223 | } 224 | }; 225 | 226 | const response = await BackgroundMessageHandlers.processGetWorkspace(request); 227 | expect(response).toEqual(MessageResponses.ERROR); 228 | }); 229 | 230 | it('should get the workspace, serialize it, and return the serialized data', async () => { 231 | const request = { 232 | payload: { 233 | uuid: '123' 234 | } 235 | }; 236 | 237 | const mockWorkspace = { 238 | serialize: jest.fn().mockReturnValue('serialized data') 239 | }; 240 | (StorageHelper.getWorkspace).mockResolvedValue(mockWorkspace); 241 | 242 | const response = await BackgroundMessageHandlers.processGetWorkspace(request); 243 | expect(StorageHelper.getWorkspace).toHaveBeenCalledWith('123'); 244 | expect(mockWorkspace.serialize).toHaveBeenCalled(); 245 | expect(response).toEqual({ data: 'serialized data' }); 246 | }); 247 | }); 248 | }); -------------------------------------------------------------------------------- /src/test/unit/background.test.js: -------------------------------------------------------------------------------- 1 | import { Background } from "../../background"; 2 | import { Constants } from "../../constants/constants"; 3 | import { TabStub } from "../../obj/tab-stub"; 4 | import { Workspace } from "../../obj/workspace"; 5 | import { StorageHelper } from "../../storage-helper"; 6 | import { SyncWorkspaceStorage } from "../../storage/sync-workspace-storage"; 7 | import { DebounceUtil } from "../../utils/debounce"; 8 | 9 | 10 | // Mock the storage helper, can't be done in beforeEach 11 | jest.mock('../../storage-helper'); 12 | 13 | describe('Background', () => { 14 | beforeEach(() => { 15 | jest.restoreAllMocks(); 16 | jest.resetAllMocks(); 17 | jest.clearAllMocks(); 18 | 19 | // Mock the listeners 20 | chrome.runtime.onMessage.addListener = jest.fn(); 21 | chrome.windows.onRemoved.addListener = jest.fn(); 22 | chrome.tabs.onRemoved.addListener = jest.fn(); 23 | chrome.tabs.onCreated.addListener = jest.fn(); 24 | }); 25 | 26 | describe('windowRemoved', () => { 27 | it('should return early when the window is not a workspace', async () => { 28 | StorageHelper.isWindowWorkspace.mockResolvedValue(false); 29 | const consoleSpy = jest.spyOn(console, 'debug').mockImplementation(); 30 | await Background.windowRemoved(1); 31 | expect(consoleSpy).not.toHaveBeenCalled(); 32 | consoleSpy.mockRestore(); 33 | }); 34 | 35 | it('should log a debug message when the window is a workspace', async () => { 36 | StorageHelper.isWindowWorkspace.mockResolvedValue(true); 37 | const consoleSpy = jest.spyOn(console, 'debug').mockImplementation(); 38 | await Background.windowRemoved(1); 39 | expect(consoleSpy).toHaveBeenCalledWith('Window 1 is a workspace, saving tabs...'); 40 | consoleSpy.mockRestore(); 41 | }); 42 | 43 | it('should immediately save the workspace to sync storage when a debounced save is present', async () => { 44 | StorageHelper.isWindowWorkspace.mockResolvedValue(true); 45 | StorageHelper.getWorkspaceFromWindow.mockResolvedValue(new Workspace('test', 1)); 46 | jest.spyOn(SyncWorkspaceStorage, 'isSyncSavingEnabled').mockResolvedValue(true); 47 | 48 | // Hackily say that the debounce timeout is set 49 | DebounceUtil.debounceTimeouts.set(Constants.DEBOUNCE_IDS.saveWorkspaceToSync, {}); 50 | 51 | const saveSpy = jest.spyOn(SyncWorkspaceStorage, 'immediatelySaveWorkspaceToSync').mockResolvedValue(); 52 | await Background.windowRemoved(1); 53 | 54 | expect(saveSpy).toHaveBeenCalled(); 55 | }); 56 | }); 57 | 58 | describe('tabRemoved', () => { 59 | it('should return early when the window is closing', async () => { 60 | const workspace = new Workspace('test', 1); 61 | workspace.removeTab = jest.fn(); 62 | 63 | await Background.tabRemoved(1, { isWindowClosing: true, windowId: 1 }); 64 | expect(workspace.removeTab).not.toHaveBeenCalled(); 65 | }); 66 | 67 | it('should update the workspace when a tab, not the window, is closing', async () => { 68 | const workspace = new Workspace('test', 1); 69 | const tab1 = TabStub.fromTab({ id: 1, windowId: 1 }); 70 | const tab2 = TabStub.fromTab({ id: 2, windowId: 1 }); 71 | workspace.addTab(TabStub.fromTab(tab1)); 72 | workspace.addTab(TabStub.fromTab(tab2)); 73 | 74 | workspace.removeTab = jest.fn(); 75 | StorageHelper.getWorkspace.mockResolvedValue(workspace); 76 | StorageHelper.isWindowWorkspace.mockResolvedValue(true); 77 | chrome.tabs.query.mockResolvedValue([tab2]); 78 | 79 | // This is our test condition 80 | StorageHelper.setWorkspace.mockImplementation((workspace) => { 81 | try { 82 | expect(workspace.getTabs()).toHaveLength(1); 83 | expect(workspace.getTabs()[0].id).toBe(2); 84 | Promise.resolve(true) 85 | } 86 | catch (e) { 87 | Promise.reject(e); 88 | } 89 | }); 90 | 91 | await Background.tabRemoved(1, { isWindowClosing: false, windowId: 1 }); 92 | 93 | expect(workspace.removeTab).not.toHaveBeenCalled(); 94 | }); 95 | 96 | it('should not update the workspace when the only tab in the workspace is closing', async () => { 97 | const workspace = new Workspace('test', 1); 98 | workspace.addTab(TabStub.fromTab({ id: 1, windowId: 1 })); 99 | 100 | workspace.removeTab = jest.fn(); 101 | Background.windowRemoved = jest.fn(); 102 | 103 | StorageHelper.getWorkspace.mockResolvedValue(workspace); 104 | StorageHelper.setWorkspace.mockResolvedValue(true); 105 | StorageHelper.isWindowWorkspace.mockResolvedValue(true); 106 | 107 | await Background.tabRemoved(1, { isWindowClosing: false, windowId: 1 }); 108 | 109 | expect(workspace.removeTab).not.toHaveBeenCalled(); 110 | }); 111 | }); 112 | 113 | describe('tabCreated', () => { 114 | it('should return early when the window is not a workspace', async () => { 115 | StorageHelper.isWindowWorkspace.mockResolvedValue(false); 116 | // Mock the getWorkspaces to reject the promise 117 | StorageHelper.getWorkspace.mockRejectedValue(false); 118 | 119 | const workspace = new Workspace(1, 'test'); 120 | workspace.tabs.push = jest.fn(); 121 | 122 | await Background.tabUpdated(1, {}, { id: 1, windowId: 1 }); 123 | 124 | expect(workspace.tabs.push).not.toHaveBeenCalled(); 125 | }); 126 | }); 127 | describe("tabAttached", () => { 128 | it("should save window tabs to workspace if the window is a workspace", async () => { 129 | // Arrange 130 | const tabId = 1; 131 | const attachInfo = { newWindowId: 2 }; 132 | const saveWindowTabsToWorkspaceSpy = jest.spyOn(Background, "saveWindowTabsToWorkspace").mockResolvedValue(); 133 | jest.spyOn(StorageHelper, "isWindowWorkspace").mockResolvedValue(true); 134 | 135 | // Act 136 | await Background.tabAttached(tabId, attachInfo); 137 | 138 | // Assert 139 | expect(saveWindowTabsToWorkspaceSpy).toHaveBeenCalledWith(attachInfo.newWindowId); 140 | }); 141 | 142 | it("should not save window tabs to workspace if the window is not a workspace", async () => { 143 | // Arrange 144 | const tabId = 1; 145 | const attachInfo = { newWindowId: 2 }; 146 | const saveWindowTabsToWorkspaceSpy = jest.spyOn(Background, "saveWindowTabsToWorkspace").mockResolvedValue(); 147 | jest.spyOn(StorageHelper, "isWindowWorkspace").mockResolvedValue(false); 148 | 149 | // Act 150 | await Background.tabAttached(tabId, attachInfo); 151 | 152 | // Assert 153 | expect(saveWindowTabsToWorkspaceSpy).not.toHaveBeenCalled(); 154 | }); 155 | }); 156 | 157 | describe("tabReplaced", () => { 158 | it("should update the replaced tab in the workspace", async () => { 159 | const addedTabId = 1; 160 | const removedTabId = 2; 161 | const windowId = 3; 162 | const mockTab = { windowId }; 163 | const saveWindowTabsToWorkspaceSpy = jest.spyOn(Background, "saveWindowTabsToWorkspace").mockResolvedValue(); 164 | jest.spyOn(StorageHelper, "isWindowWorkspace").mockResolvedValue(true); 165 | chrome.tabs.get.mockResolvedValue(mockTab); 166 | 167 | await Background.tabReplaced(addedTabId, removedTabId); 168 | 169 | expect(saveWindowTabsToWorkspaceSpy).toHaveBeenCalledWith(windowId); 170 | }); 171 | 172 | it("should not call saveWindowTabsToWorkspace if the window is not a workspace", async () => { 173 | const addedTabId = 1; 174 | const removedTabId = 2; 175 | const windowId = 3; 176 | const mockTab = { windowId }; 177 | const saveWindowTabsToWorkspaceSpy = jest.spyOn(Background, "saveWindowTabsToWorkspace").mockResolvedValue(); 178 | jest.spyOn(StorageHelper, "isWindowWorkspace").mockResolvedValue(false); 179 | chrome.tabs.get.mockResolvedValue(mockTab); 180 | 181 | await Background.tabReplaced(addedTabId, removedTabId); 182 | 183 | expect(saveWindowTabsToWorkspaceSpy).not.toHaveBeenCalled(); 184 | }); 185 | }); 186 | 187 | // Most of the logic in this function is already tested for tabRemoved 188 | describe("tabDetached", () => { 189 | it("should have cleared the tab's badge text", async () => { 190 | const tabId = 1; 191 | const tab = { id: tabId, windowId: 1 }; 192 | const detachInfo = { oldWindowId: 2 }; 193 | chrome.tabs.get.mockResolvedValue(tab); 194 | 195 | await Background.tabDetached(tabId, detachInfo); 196 | 197 | expect(chrome.action.setBadgeText).toHaveBeenCalledWith({ text: "", tabId }); 198 | }); 199 | }); 200 | }); -------------------------------------------------------------------------------- /src/test/unit/bookmark-storage-helper.test.js: -------------------------------------------------------------------------------- 1 | import { Constants } from '../../constants/constants'; 2 | import { TabStub } from "../../obj/tab-stub"; 3 | import { Workspace } from '../../obj/workspace'; 4 | import { BookmarkStorageHelper } from '../../storage/bookmark-storage-helper'; 5 | 6 | jest.mock('../../storage-helper'); 7 | 8 | describe('BookmarkStorageHelper', () => { 9 | beforeEach(() => { 10 | // Mock chrome.bookmarks API 11 | global.chrome = { 12 | bookmarks: { 13 | getTree: jest.fn().mockResolvedValue([ 14 | { 15 | id: '1', 16 | title: '', 17 | children: [ 18 | { 19 | id: '2', 20 | title: "Bookmarks bar", 21 | children: [] 22 | }, 23 | { 24 | id: '3', 25 | title: "Other bookmarks", 26 | children: [] 27 | } 28 | ] 29 | } 30 | ]), 31 | create: jest.fn().mockImplementation((bookmark) => Promise.resolve({ id: '3', ...bookmark })), 32 | search: jest.fn().mockResolvedValue([]), 33 | removeTree: jest.fn().mockResolvedValue() 34 | } 35 | }; 36 | 37 | jest.spyOn(BookmarkStorageHelper, "isBookmarkSaveEnabled").mockResolvedValue(true); 38 | }); 39 | 40 | afterEach(() => { 41 | jest.clearAllMocks(); 42 | }); 43 | 44 | test('getExtensionBookmarkFolder should retrieve or create the extension bookmark folder', async () => { 45 | const folder = await BookmarkStorageHelper.getExtensionBookmarkFolder(); 46 | expect(folder).toBeDefined(); 47 | expect(folder?.title).toBe(Constants.BOOKMARKS_FOLDER_NAME); 48 | }); 49 | 50 | test('saveWorkspace should save the workspace bookmark folder correctly', async () => { 51 | const workspace = new Workspace(1, 'Test Workspace', []); 52 | await BookmarkStorageHelper.saveWorkspace(workspace); 53 | 54 | expect(chrome.bookmarks.create).toHaveBeenCalledWith({ title: 'Test Workspace', parentId: expect.any(String) }); 55 | }); 56 | 57 | test('saveWorkspace should save the workspace bookmark tabs', async () => { 58 | const workspace = new Workspace(1, 'Test Workspace', [ 59 | TabStub.fromTab({ id: '1', title: 'Tab 1', url: 'http://example.com' }), 60 | TabStub.fromTab({ id: '2', title: 'Tab 2', url: 'http://example2.com' }) 61 | ]); 62 | await BookmarkStorageHelper.saveWorkspace(workspace); 63 | 64 | expect(chrome.bookmarks.create).toHaveBeenCalledWith({ title: 'Test Workspace', parentId: expect.any(String) }); 65 | expect(chrome.bookmarks.create).toHaveBeenCalledWith({ title: 'Tab 1', url: 'http://example.com', parentId: expect.any(String) }); 66 | expect(chrome.bookmarks.create).toHaveBeenCalledWith({ title: 'Tab 2', url: 'http://example2.com', parentId: expect.any(String) }); 67 | }); 68 | 69 | test('nothing should be saved to bookmarks when the setting is disabled', async () => { 70 | jest.spyOn(BookmarkStorageHelper, "isBookmarkSaveEnabled").mockResolvedValue(false); 71 | 72 | const workspace = new Workspace(1, 'Test Workspace', []); 73 | await BookmarkStorageHelper.saveWorkspace(workspace); 74 | 75 | expect(chrome.bookmarks.create).not.toHaveBeenCalled(); 76 | }); 77 | 78 | describe("Remove workspace", () => { 79 | let workspace; 80 | let bookmarkFolder; 81 | 82 | beforeEach(() => { 83 | workspace = new Workspace(1, "Test Workspace", []); 84 | bookmarkFolder = { 85 | id: "1", 86 | title: Constants.BOOKMARKS_FOLDER_NAME, 87 | children: [ 88 | { 89 | id: "2", 90 | title: "Test Workspace", 91 | children: [] 92 | } 93 | ] 94 | }; 95 | 96 | // Copy of the mocked structure from the start of the file 97 | jest.spyOn(chrome.bookmarks, "getTree").mockResolvedValue([{ 98 | id: '1', 99 | title: '', 100 | children: [ 101 | { 102 | id: '2', 103 | title: "Bookmarks bar", 104 | children: [] 105 | }, 106 | { 107 | id: '3', 108 | title: "Other bookmarks", 109 | children: [bookmarkFolder] 110 | } 111 | ] 112 | }]); 113 | }); 114 | 115 | afterEach(() => { 116 | jest.clearAllMocks(); 117 | }); 118 | 119 | it("removeWorkspace removes the workspace folder if it exists", async () => { 120 | await BookmarkStorageHelper.removeWorkspace(workspace); 121 | 122 | expect(chrome.bookmarks.removeTree).toHaveBeenCalledWith("2"); 123 | }); 124 | 125 | test("removeWorkspace does nothing if the workspace folder does not exist", async () => { 126 | bookmarkFolder.children = []; 127 | 128 | await BookmarkStorageHelper.removeWorkspace(workspace); 129 | 130 | expect(chrome.bookmarks.removeTree).not.toHaveBeenCalled(); 131 | }); 132 | 133 | test("removeWorkspace logs an error if the bookmark folder cannot be found", async () => { 134 | jest.spyOn(console, "error").mockImplementation(() => { }); 135 | jest.spyOn(BookmarkStorageHelper, "getExtensionBookmarkFolder").mockResolvedValue(undefined); 136 | 137 | await expect(BookmarkStorageHelper.removeWorkspace(workspace)).rejects.toEqual("Could not find the bookmark folder."); 138 | 139 | expect(console.error).toHaveBeenCalled(); 140 | }); 141 | 142 | test("removeWorkspace skips if bookmark saving is disabled", async () => { 143 | jest.spyOn(BookmarkStorageHelper, "isBookmarkSaveEnabled").mockResolvedValue(false); 144 | 145 | await BookmarkStorageHelper.removeWorkspace(workspace); 146 | 147 | expect(chrome.bookmarks.removeTree).not.toHaveBeenCalled(); 148 | }); 149 | }); 150 | }); -------------------------------------------------------------------------------- /src/test/unit/message-responses.test.js: -------------------------------------------------------------------------------- 1 | const { MessageResponses } = require("../../constants/message-responses"); 2 | 3 | 4 | describe('MessageResponses', () => { 5 | function testMessageResponse(response, val) { 6 | expect(response).not.toBeUndefined(); 7 | expect(response).toHaveProperty("message"); 8 | expect(response.message).toBe(val); 9 | } 10 | 11 | it('should correctly construct OK response', () => { 12 | const response = MessageResponses.OK; 13 | 14 | testMessageResponse(response, 'OK'); 15 | }); 16 | 17 | it('should correctly construct ERROR response', () => { 18 | const response = MessageResponses.ERROR; 19 | testMessageResponse(response, 'ERROR'); 20 | }); 21 | 22 | it('should correctly construct SUCCESS response', () => { 23 | const response = MessageResponses.SUCCESS; 24 | testMessageResponse(response, 'SUCCESS'); 25 | }); 26 | }); -------------------------------------------------------------------------------- /src/test/unit/storage-helper.test.js: -------------------------------------------------------------------------------- 1 | import { Constants } from "../../constants/constants"; 2 | import { Workspace } from "../../obj/workspace"; 3 | import { StorageHelper } from "../../storage-helper"; 4 | import { SyncWorkspaceStorage } from "../../storage/sync-workspace-storage"; 5 | 6 | const map = new Map(); 7 | 8 | beforeEach(() => { 9 | // Mock chrome.storage.local get and set to use our map local variable 10 | jest.spyOn(StorageHelper, "getValue").mockImplementation((key, defaultValue) => { 11 | return map.get(key) || defaultValue; 12 | }); 13 | 14 | jest.spyOn(StorageHelper, "setValue").mockImplementation((key, value) => { 15 | map.set(key, value); 16 | return true; 17 | }); 18 | map.clear(); 19 | // We're not testing the sync storage here, so mock it to return false to skip the logic 20 | jest.spyOn(SyncWorkspaceStorage, "isSyncSavingEnabled").mockResolvedValue(false); 21 | }); 22 | 23 | /** Jest does not clear any mock state between tests (which is baffling). So doing this and/or putting 24 | * restoreMocks: true, 25 | * clearMocks: true, 26 | * resetMocks: true, 27 | * In jest.config.js is necessary to prevent tests from sharing mock state between them. 28 | */ 29 | afterEach(() => { 30 | jest.restoreAllMocks(); 31 | jest.resetAllMocks(); 32 | jest.clearAllMocks(); 33 | }); 34 | 35 | 36 | describe("addWorkspace", () => { 37 | it("should successfully add a workspace", async () => { 38 | jest.spyOn(chrome.storage.local, "get").mockResolvedValue(3); 39 | jest.spyOn(chrome.storage.local, "set").mockResolvedValue("success"); 40 | const window = { 41 | id: 1, 42 | focused: true 43 | }; 44 | expect(await StorageHelper.addWorkspace("name", window.id)).toBe(true); 45 | }); 46 | 47 | it("should reject when window id is null or undefined", async () => { 48 | let mockWindow = { id: null, tabs: [] }; 49 | 50 | await expect(StorageHelper.addWorkspace("testWorkspaceFail", mockWindow.id)) 51 | .resolves.toBe(false); 52 | }); 53 | 54 | it.skip("should add workspace", async () => { 55 | // Mock the get and setWorkspace methods 56 | jest.spyOn(StorageHelper, "getWorkspaces").mockResolvedValue(new Map()); 57 | jest.spyOn(StorageHelper, "setWorkspaces").mockImplementation(() => { return true; }); 58 | 59 | let mockWindow = { id: 1, tabs: [] }; 60 | let workspaces = new Map(); 61 | workspaces.set(mockWindow.id, new Workspace(mockWindow.id, "testWorkspaceAdd", mockWindow.tabs)); 62 | const result = await StorageHelper.addWorkspace("testWorkspaceAdd", mockWindow.id); 63 | 64 | expect(result).toBe(true); 65 | expect(StorageHelper.getWorkspaces).toHaveBeenCalledTimes(1); 66 | expect(StorageHelper.setWorkspaces).toHaveBeenCalledWith(workspaces); 67 | 68 | }); 69 | 70 | it.skip("should add two workspaces and get them", async () => { 71 | let map = new Map(); 72 | 73 | // Mock chrome.storage.local get and set to use our map local variable 74 | jest.spyOn(StorageHelper, "getValue").mockImplementation((key, defaultValue) => { 75 | return map.get(key) || defaultValue; 76 | }); 77 | 78 | jest.spyOn(StorageHelper, "setValue").mockImplementation((key, value) => { 79 | map.set(key, value); 80 | return true; 81 | }); 82 | 83 | await StorageHelper.addWorkspace("testWorkspaceAddOne", 1); 84 | await StorageHelper.addWorkspace("testWorkspaceAddTwo", 2); 85 | 86 | let workspaces = await StorageHelper.getWorkspaces(); 87 | 88 | expect(workspaces.size).toBe(2); 89 | 90 | }); 91 | }); 92 | 93 | describe("chrome local storage", () => { 94 | it("should get value", async () => { 95 | const key = "testKey"; 96 | const defaultValue = "defaultValue"; 97 | const value = "testValue"; 98 | 99 | // jest.spyOn(chrome.storage.local, "get").mockResolvedValue({ [key]: value }); 100 | await StorageHelper.setValue(key, value); 101 | const result = await StorageHelper.getValue(key, defaultValue); 102 | 103 | expect(result).toBe(value); 104 | }); 105 | }); 106 | 107 | describe("setWorkspace", () => { 108 | it("should update workspaces", async () => { 109 | // Arrange 110 | jest.spyOn(chrome.storage.local, "get").mockResolvedValue(new Map()); 111 | jest.spyOn(chrome.storage.local, "set").mockResolvedValue("success"); 112 | jest.spyOn(StorageHelper, "setWorkspaces").mockResolvedValue(true); 113 | 114 | let workspace = new Workspace(2, "testWorkspaceSet"); 115 | await StorageHelper.setWorkspace(workspace); 116 | 117 | // Assert 118 | expect(StorageHelper.setWorkspaces).toHaveBeenCalledTimes(1); 119 | // let workspaces = await StorageHelper.getWorkspaces(); 120 | // expect(workspaces.get(workspace.windowId)).toEqual(workspace); 121 | 122 | }); 123 | 124 | }); 125 | 126 | describe("getWorkspaces", () => { 127 | it.skip("should call getValue with correct parameters", async () => { 128 | // Arrange 129 | const workspaces = new Map(); 130 | let workspace = new Workspace(3, "toGet"); 131 | workspaces.set(workspace.windowId, workspace); 132 | const stringValue = JSON.stringify(Array.from(workspaces)); 133 | 134 | jest.spyOn(chrome.storage.local, "get").mockResolvedValue({ [Constants.KEY_STORAGE_WORKSPACES]: stringValue }); 135 | 136 | // Act 137 | let value = await StorageHelper.getWorkspaces(); 138 | 139 | // Assert 140 | expect(value).toEqual(workspaces); 141 | }); 142 | 143 | it("should return the workspaces from storage", async () => { 144 | // Arrange 145 | let workspace = new Workspace(3, "toGet"); 146 | await StorageHelper.setWorkspace(workspace); 147 | 148 | // Act 149 | let value = await StorageHelper.getWorkspaces(); 150 | 151 | // Assert 152 | expect(value.get(workspace.uuid)).toEqual(workspace); 153 | }); 154 | }); 155 | 156 | describe("clearWorkspaceData", () => { 157 | it("should clear the workspaces", async () => { 158 | // Arrange 159 | jest.spyOn(chrome.storage.local, "clear").mockResolvedValue("success"); 160 | 161 | // Act 162 | await StorageHelper.clearWorkspaces(); 163 | 164 | // Assert 165 | expect(chrome.storage.local.clear).toHaveBeenCalledTimes(1); 166 | }); 167 | }); 168 | 169 | describe("renameWorkspace", () => { 170 | it("should rename the workspace", async () => { 171 | // Arrange 172 | let workspace = new Workspace(3, "toRename"); 173 | await StorageHelper.setWorkspace(workspace); 174 | 175 | // Act 176 | await StorageHelper.renameWorkspace(workspace.uuid, "newName"); 177 | // Assert 178 | let workspaces = await StorageHelper.getWorkspaces(); 179 | expect(workspaces.get(workspace.uuid).name).toBe("newName"); 180 | }); 181 | 182 | it("should return false if the workspace does not exist", async () => { 183 | expect(await StorageHelper.renameWorkspace("notExist", "newName")).toBe(false); 184 | }); 185 | }); 186 | 187 | describe("removeWorkspace", () => { 188 | it("should remove the workspace", async () => { 189 | // Arrange 190 | let workspace = new Workspace(3, "toRemove"); 191 | await StorageHelper.setWorkspace(workspace); 192 | 193 | // Act 194 | await StorageHelper.removeWorkspace(workspace.uuid); 195 | 196 | // Assert 197 | let workspaces = await StorageHelper.getWorkspaces(); 198 | expect(workspaces.get(workspace.uuid)).toBeUndefined(); 199 | }); 200 | 201 | it("should return false if the workspace does not exist", async () => { 202 | expect(await StorageHelper.removeWorkspace("notExist")).toBe(false); 203 | }); 204 | }); 205 | 206 | describe("isWindowWorkspace", () => { 207 | it("should return true if the window is a workspace", async () => { 208 | let workspace = new Workspace(3, "toCheck"); 209 | await StorageHelper.setWorkspace(workspace); 210 | 211 | let result = await StorageHelper.isWindowWorkspace(workspace.windowId); 212 | 213 | expect(result).toBe(true); 214 | }); 215 | 216 | it("should return false if the window is not a workspace", async () => { 217 | let workspace = new Workspace(3, "toCheck"); 218 | await StorageHelper.setWorkspace(workspace); 219 | 220 | let result = await StorageHelper.isWindowWorkspace(4); 221 | 222 | expect(result).toBe(false); 223 | }); 224 | 225 | it("should return false if the windowId is null", async () => { 226 | expect(await StorageHelper.isWindowWorkspace(null)).toBe(false); 227 | }); 228 | }); 229 | 230 | test("getKeysByPrefix returns keys from chrome.storage.sync with a given prefix", async () => { 231 | const keys = [ 232 | "workspace_metadata_workspace-uuid", 233 | "workspace_tabs_workspace-uuid_0", 234 | "workspace_tab_groups_workspace-uuid", 235 | "non_workspace_key", 236 | "2nd_workspace_metadata_workspace-uuid", 237 | ]; 238 | 239 | chrome.storage.sync.get.mockResolvedValue(keys.reduce((acc, key) => { 240 | acc[key] = []; 241 | return acc; 242 | }, {})); 243 | 244 | const result = await StorageHelper.getKeysByPrefix("workspace_"); 245 | 246 | expect(result).toEqual([ 247 | "workspace_metadata_workspace-uuid", 248 | "workspace_tabs_workspace-uuid_0", 249 | "workspace_tab_groups_workspace-uuid", 250 | ]); 251 | }); 252 | -------------------------------------------------------------------------------- /src/test/unit/utils.test.js: -------------------------------------------------------------------------------- 1 | import { Workspace } from '../../obj/workspace'; 2 | import { StorageHelper } from '../../storage-helper'; 3 | import { TabStub } from '../../obj/tab-stub'; 4 | import { Utils } from "../../utils"; 5 | import { TabGroupStub } from "../../obj/tab-group-stub"; 6 | 7 | jest.mock('../../storage-helper'); 8 | jest.mock('../../obj/tab-stub'); 9 | 10 | describe('Utils', () => { 11 | describe('getTabsFromWindow', () => { 12 | it('should call chrome.tabs.query with the correct windowId', async () => { 13 | const mockTabs = [{ id: 1, windowId: 1, url: 'http://test.com' }]; 14 | global.chrome = { 15 | tabs: { 16 | query: jest.fn().mockResolvedValue(mockTabs), 17 | }, 18 | }; 19 | 20 | const windowId = 1; 21 | const tabs = await Utils.getTabsFromWindow(windowId); 22 | 23 | expect(global.chrome.tabs.query).toHaveBeenCalledWith({ windowId }); 24 | expect(tabs).toEqual(mockTabs); 25 | }); 26 | }); 27 | 28 | describe('setWorkspaceTabs', () => { 29 | it('should set the tabs of the workspace and save it', async () => { 30 | const mockTabs = [{ id: 1, windowId: 1, url: 'http://test.com' }]; 31 | const workspace = new Workspace('test', 1); 32 | 33 | TabStub.fromTabs = jest.fn().mockReturnValue(mockTabs); 34 | StorageHelper.setWorkspace = jest.fn().mockResolvedValue(undefined); 35 | 36 | await Utils.setWorkspaceTabs(workspace, mockTabs); 37 | 38 | expect(workspace.getTabs()).toEqual(mockTabs); 39 | expect(StorageHelper.setWorkspace).toHaveBeenCalledWith(workspace); 40 | }); 41 | }); 42 | 43 | describe('getTabGroupsFromWindow', () => { 44 | it('should return the tab groups of the window', async () => { 45 | const windowId = 1; 46 | const tabGroupData = [ 47 | { collapsed: false, color: 'blue', title: 'group1', id: 1, windowId }, 48 | { collapsed: false, color: 'grey', title: 'group2', id: 2, windowId }, 49 | { collapsed: false, color: 'pink', title: 'group3', id: 3, windowId }, 50 | ]; 51 | global.chrome = { 52 | tabGroups: { 53 | query: jest.fn().mockResolvedValue(tabGroupData), 54 | } 55 | }; 56 | 57 | const tabGroups = await Utils.getTabGroupsFromWindow(windowId); 58 | 59 | expect(tabGroups).toEqual(tabGroupData); 60 | }); 61 | }); 62 | 63 | describe('setWorkspaceTabGroups', () => { 64 | it('should set the tab groups of the workspace and save it', async () => { 65 | const tabGroups = [ 66 | { collapsed: false, color: 'blue', title: 'group1', id: 1 }, 67 | { collapsed: false, color: 'grey', title: 'group2', id: 2 }, 68 | { collapsed: false, color: 'pink', title: 'group3', id: 3 }, 69 | ]; 70 | const mockTabs = [{ id: 1, windowId: 1, url: 'http://test.com' }]; 71 | const workspace = new Workspace('test', 1); 72 | 73 | StorageHelper.setWorkspace = jest.fn().mockResolvedValue(undefined); 74 | TabStub.fromTabs = jest.fn().mockReturnValue(mockTabs); 75 | TabGroupStub.fromTabGroups = jest.fn().mockReturnValue(tabGroups); 76 | 77 | await Utils.setWorkspaceTabsAndGroups(workspace, mockTabs, tabGroups); 78 | 79 | expect(workspace.getTabGroups()).toEqual(tabGroups); 80 | expect(StorageHelper.setWorkspace).toHaveBeenCalledWith(workspace); 81 | }); 82 | }); 83 | }); -------------------------------------------------------------------------------- /src/test/unit/utils/chunk.test.js: -------------------------------------------------------------------------------- 1 | const { ChunkUtil } = require("../../../utils/chunk"); 2 | 3 | describe("Chunk util", () => { 4 | test("chunkArray chunks an array into smaller arrays based on byte size", () => { 5 | const array = ["item1", "item2", "item3"]; 6 | const maxBytes = 10; // Small size for testing 7 | const chunks = ChunkUtil.chunkArray(array, maxBytes); 8 | 9 | expect(chunks.length).toBeGreaterThan(1); 10 | }); 11 | 12 | test("unChunkArray unchunks an array of arrays into a single array", () => { 13 | const array = ["item1", "item2", "item3"]; 14 | const maxBytes = 10; // Small size for testing 15 | const chunks = ChunkUtil.chunkArray(array, maxBytes); 16 | 17 | const unchunked = ChunkUtil.unChunkArray(chunks); 18 | 19 | expect(unchunked.length).toBe(array.length); 20 | }); 21 | }); -------------------------------------------------------------------------------- /src/test/unit/utils/workspace-utils.test.js: -------------------------------------------------------------------------------- 1 | import { SyncWorkspaceTombstone } from "../../../storage/sync-workspace-storage"; 2 | import { WorkspaceUtils } from "../../../utils/workspace-utils"; 3 | import { WorkspaceStorage } from "../../../workspace-storage"; 4 | 5 | describe('WorkspaceUtils', () => { 6 | let localStorage; 7 | let syncStorage; 8 | let syncStorageTombstones; 9 | 10 | beforeEach(() => { 11 | localStorage = new WorkspaceStorage(); 12 | syncStorage = new WorkspaceStorage(); 13 | syncStorageTombstones = []; 14 | 15 | jest.restoreAllMocks(); 16 | jest.resetAllMocks(); 17 | jest.clearAllMocks(); 18 | }); 19 | 20 | describe('syncWorkspaces', () => { 21 | function defaultConflictResolver(_local, _sync) { 22 | return false; 23 | } 24 | dCR = defaultConflictResolver; 25 | 26 | it('should delete local workspaces that have a corresponding tombstone', () => { 27 | const localWorkspace = { uuid: '1', lastUpdated: 1 }; 28 | const tombstone = { uuid: '1', timestamp: 2 }; 29 | const syncWorkspace = { uuid: '2', lastUpdated: 10 }; 30 | 31 | localStorage.set('1', localWorkspace); 32 | localStorage.set('2', syncWorkspace); 33 | syncStorageTombstones.push(tombstone); 34 | syncStorage.set('2', syncWorkspace); 35 | 36 | const [updatedLocalStorage, updatedSyncStorage] = WorkspaceUtils.syncWorkspaces(localStorage, syncStorage, syncStorageTombstones, dCR); 37 | 38 | expect(updatedLocalStorage.has('1')).toBe(false); 39 | expect(updatedSyncStorage.has('2')).toBe(true); // Should not be affected 40 | }); 41 | 42 | it('should use the conflictResolver callback when local workspace updated after tombstone creation', () => { 43 | const localWorkspace = { uuid: '1', lastUpdated: 3 }; 44 | const tombstone = { uuid: '1', timestamp: 2 }; 45 | 46 | localStorage.set('1', localWorkspace); 47 | syncStorageTombstones.push(tombstone); 48 | 49 | const [updatedLocalStorage, updatedSyncStorage] = WorkspaceUtils.syncWorkspaces(localStorage, syncStorage, syncStorageTombstones, 50 | (_local, _sync) => { 51 | return true; // Always keep local 52 | } 53 | ); 54 | 55 | expect(updatedLocalStorage.has('1')).toBe(true); 56 | expect(updatedLocalStorage.get('1')).toEqual(localWorkspace); 57 | }); 58 | 59 | it('should use the conflictResolver callback and delete workspace, when local workspace updated after tombstone creation', () => { 60 | const localWorkspace = { uuid: '1', lastUpdated: 4 }; 61 | const tombstone = { uuid: '1', timestamp: 2 }; 62 | 63 | localStorage.set('1', localWorkspace); 64 | syncStorageTombstones.push(tombstone); 65 | 66 | const [updatedLocalStorage, updatedSyncStorage] = WorkspaceUtils.syncWorkspaces(localStorage, syncStorage, syncStorageTombstones, 67 | (_local, _sync) => { 68 | return false; // Always delete 69 | } 70 | ); 71 | 72 | expect(updatedLocalStorage.has('1')).toBe(false); 73 | }); 74 | 75 | it('should merge storages and keep the more up-to-date workspace in local storage', () => { 76 | const localWorkspace = { uuid: '1', lastUpdated: 2 }; 77 | const syncWorkspace = { uuid: '1', lastUpdated: 1 }; 78 | 79 | localStorage.set('1', localWorkspace); 80 | localStorage.set('2', { uuid: '2', lastUpdated: 10 }); 81 | syncStorage.set('1', syncWorkspace); 82 | syncStorage.set('3', { uuid: '3', lastUpdated: 5 }); 83 | 84 | const [updatedLocalStorage, updatedSyncStorage] = WorkspaceUtils.syncWorkspaces(localStorage, syncStorage, syncStorageTombstones, dCR); 85 | 86 | expect(updatedSyncStorage.get('1')).toEqual(localWorkspace); 87 | expect(updatedLocalStorage.get('1')).toEqual(localWorkspace); 88 | }); 89 | it('should merge storages and keep the more up-to-date workspace in sync storage', () => { 90 | const localWorkspace = { uuid: '1', lastUpdated: 1 }; 91 | const syncWorkspace = { uuid: '1', lastUpdated: 2 }; 92 | 93 | localStorage.set('1', localWorkspace); 94 | localStorage.set('2', { uuid: '2', lastUpdated: 10 }); 95 | syncStorage.set('1', syncWorkspace); 96 | syncStorage.set('3', { uuid: '3', lastUpdated: 5 }); 97 | 98 | const [updatedLocalStorage, updatedSyncStorage] = WorkspaceUtils.syncWorkspaces(localStorage, syncStorage, syncStorageTombstones, dCR); 99 | 100 | expect(updatedSyncStorage.get('1')).toEqual(syncWorkspace); 101 | expect(updatedLocalStorage.get('1')).toEqual(syncWorkspace); 102 | }); 103 | 104 | it('should include workspaces that are only in local storage', () => { 105 | const localWorkspace = { uuid: '1', lastUpdated: 2 }; 106 | 107 | localStorage.set('1', localWorkspace); 108 | 109 | const [_, updatedSyncStorage] = WorkspaceUtils.syncWorkspaces(localStorage, syncStorage, syncStorageTombstones, dCR); 110 | 111 | expect(updatedSyncStorage.get('1')).toEqual(localWorkspace); 112 | }); 113 | 114 | it('should include workspaces that are only in sync storage', () => { 115 | const syncWorkspace = { uuid: '1', lastUpdated: 1 }; 116 | 117 | syncStorage.set('1', syncWorkspace); 118 | 119 | const [updatedLocalStorage, _] = WorkspaceUtils.syncWorkspaces(localStorage, syncStorage, syncStorageTombstones, dCR); 120 | 121 | expect(updatedLocalStorage.get('1')).toEqual(syncWorkspace); 122 | }); 123 | 124 | it('should not fail if a tombstone is not in the local storage', () => { 125 | const tombstone = { uuid: '1', timestamp: 2 }; 126 | const syncWorkspace = { uuid: '2', lastUpdated: 1 }; 127 | const localWorkspace = { uuid: '2', lastUpdated: 1 }; 128 | 129 | syncStorageTombstones.push(tombstone); 130 | syncStorage.set('2', syncWorkspace); 131 | localStorage.set('2', localWorkspace); 132 | 133 | const [updatedLocalStorage, updatedSyncStorage] = WorkspaceUtils.syncWorkspaces(localStorage, syncStorage, syncStorageTombstones, dCR); 134 | 135 | expect(updatedLocalStorage.has('1')).toBe(false); 136 | expect(updatedSyncStorage.has('1')).toBe(false); 137 | expect(updatedLocalStorage.has('2')).toBe(true); 138 | expect(updatedSyncStorage.has('2')).toBe(true); 139 | }); 140 | 141 | it('should not fail if a local workspace does not have a lastUpdated field and its deleted by a tombstone', () => { 142 | const localWorkspace = { uuid: '1' }; 143 | const syncWorkspace = { uuid: '2', lastUpdated: 1 }; 144 | const tombstone = { uuid: '1', timestamp: 2 }; 145 | 146 | localStorage.set('1', localWorkspace); 147 | syncStorage.set('2', syncWorkspace); 148 | syncStorageTombstones.push(tombstone); 149 | 150 | const [updatedLocalStorage, updatedSyncStorage] = WorkspaceUtils.syncWorkspaces(localStorage, syncStorage, syncStorageTombstones, dCR); 151 | 152 | expect(updatedLocalStorage.get('2')).toEqual(syncWorkspace); 153 | expect(updatedLocalStorage.has('1')).toBe(false); 154 | }); 155 | 156 | it('should make no changes after being run once', () => { 157 | const localWorkspace = { uuid: '1', lastUpdated: 2 }; 158 | const syncWorkspace = { uuid: '1', lastUpdated: 1 }; 159 | 160 | localStorage.set('1', localWorkspace); 161 | localStorage.set('2', { uuid: '2', lastUpdated: 10 }); 162 | syncStorage.set('1', syncWorkspace); 163 | syncStorage.set('3', { uuid: '3', lastUpdated: 5 }); 164 | 165 | const [updatedLocalStorage, updatedSyncStorage] = WorkspaceUtils.syncWorkspaces(localStorage, syncStorage, syncStorageTombstones, dCR); 166 | const [updatedLocalStorage2, updatedSyncStorage2] = WorkspaceUtils.syncWorkspaces(updatedLocalStorage, updatedSyncStorage, syncStorageTombstones, dCR); 167 | 168 | expect(updatedLocalStorage2).toEqual(updatedLocalStorage); 169 | expect(updatedSyncStorage2).toEqual(updatedSyncStorage); 170 | }); 171 | 172 | it('should return a list of workspace UUIDs to be deleted from sync storage if tombstones exist', () => { 173 | const localWorkspace = { uuid: '1', lastUpdated: 2 }; 174 | const syncWorkspace = { uuid: '1', lastUpdated: 1 }; 175 | const tombstone = { uuid: '1', timestamp: 3 }; 176 | 177 | localStorage.set('1', localWorkspace); 178 | syncStorage.set('1', syncWorkspace); 179 | syncStorageTombstones.push(tombstone); 180 | 181 | const [_, updatedSyncStorage, deletedWorkspaces] = WorkspaceUtils.syncWorkspaces(localStorage, syncStorage, syncStorageTombstones, dCR); 182 | 183 | expect(deletedWorkspaces).toEqual(['1']); 184 | }); 185 | }); 186 | }); -------------------------------------------------------------------------------- /src/test/unit/workspace-storage.test.js: -------------------------------------------------------------------------------- 1 | import { TabStub } from "../../obj/tab-stub"; 2 | import { Workspace } from "../../obj/workspace"; 3 | import { WorkspaceStorage } from "../../workspace-storage"; 4 | 5 | describe('WorkspaceStorage', () => { 6 | /** 7 | * @type {WorkspaceStorage} 8 | */ 9 | let workspaceStorage; 10 | /** 11 | * @type {Workspace} 12 | */ 13 | let workspace; 14 | 15 | beforeEach(() => { 16 | workspaceStorage = new WorkspaceStorage(); 17 | workspace = new Workspace(); // Initialize Workspace with appropriate values 18 | }); 19 | afterEach(() => { 20 | workspaceStorage.clear(); 21 | }); 22 | 23 | test('should initialize with size 0', () => { 24 | expect(workspaceStorage.size).toBe(0); 25 | }); 26 | 27 | test('should add a workspace and increase size', () => { 28 | workspaceStorage.set('test', workspace); 29 | expect(workspaceStorage.size).toBe(1); 30 | }); 31 | 32 | test('should retrieve a workspace by key', () => { 33 | workspaceStorage.set('test', workspace); 34 | expect(workspaceStorage.get('test')).toBe(workspace); 35 | }); 36 | 37 | test('should delete a workspace by key', () => { 38 | workspaceStorage.set('test', workspace); 39 | workspaceStorage.delete('test'); 40 | expect(workspaceStorage.get('test')).toBeUndefined(); 41 | }); 42 | 43 | test('should clear all workspaces', () => { 44 | workspaceStorage.set('test', workspace); 45 | workspaceStorage.clear(); 46 | expect(workspaceStorage.size).toBe(0); 47 | }); 48 | 49 | test('should check if a workspace exists', () => { 50 | workspaceStorage.set('test', workspace); 51 | expect(workspaceStorage.has('test')).toBe(true); 52 | }); 53 | 54 | test('should iterate over workspaces with .entries()', () => { 55 | workspaceStorage.set('test', workspace); 56 | const entries = [...workspaceStorage]; 57 | expect(entries).toEqual([['test', workspace]]); 58 | }); 59 | 60 | test('should return only UUIDs from .keys()', () => { 61 | workspace.uuid = '123e4567-e89b-12d3-a456-426614174000'; 62 | workspace.windowId = 13; 63 | workspaceStorage.set(workspace.uuid, workspace); 64 | const keys = [...workspaceStorage.keys()]; 65 | expect(keys).toEqual([workspace.uuid]); 66 | }); 67 | 68 | test('should return all values', () => { 69 | workspaceStorage.set('test', workspace); 70 | const values = [...workspaceStorage.values()]; 71 | expect(values).toEqual([workspace]); 72 | }); 73 | 74 | test('should get a workspace by UUID', () => { 75 | const uuid = '123e4567-e89b-12d3-a456-426614174000'; // replace with actual UUID 76 | workspace.uuid = uuid; 77 | workspaceStorage.set(uuid, workspace); 78 | expect(workspaceStorage.get(uuid)).toBe(workspace); 79 | }); 80 | 81 | test('should get a workspace by windowId', () => { 82 | const windowId = 1; // replace with actual windowId 83 | workspace.windowId = windowId; 84 | workspaceStorage.set('test', workspace); 85 | expect(workspaceStorage.get(windowId)).toBe(workspace); 86 | }); 87 | 88 | test('should set workspace by UUID and get by windowId', () => { 89 | const uuid = '123e4567-e89b-12d3-a456-426614174000'; // replace with actual UUID 90 | workspace.uuid = uuid; 91 | workspaceStorage.set(uuid, workspace); 92 | expect(workspaceStorage.get(uuid)).toBe(workspace); 93 | expect(workspaceStorage.get(workspace.windowId)).toBe(workspace); 94 | }); 95 | 96 | test('should set workspace by windowId and get by UUID', () => { 97 | const windowId = 1; // replace with actual windowId 98 | workspace.windowId = windowId; 99 | workspaceStorage.set(windowId, workspace); 100 | expect(workspaceStorage.get(windowId)).toBe(workspace); 101 | expect(workspaceStorage.get(workspace.uuid)).toBe(workspace); 102 | }); 103 | 104 | test('should serialize and deserialize correctly', () => { 105 | const uuid = '123e4567-e89b-12d3-a456-426614174000'; 106 | const now = Date.now(); 107 | workspace.uuid = uuid; 108 | workspace.windowId = 1; 109 | workspace.addTab(new TabStub({ url: 'https://example.com', id: 12, index: 0})); 110 | workspace.lastUpdated = now; 111 | workspaceStorage.set(uuid, workspace); 112 | 113 | const serialized = workspaceStorage.serialize(); 114 | const newWorkspaceStorage = new WorkspaceStorage(); 115 | newWorkspaceStorage.deserialize(serialized); 116 | 117 | expect(newWorkspaceStorage.size).toBe(1); 118 | expect(newWorkspaceStorage.get(uuid).getTab(12).url).toBe('https://example.com'); 119 | expect(newWorkspaceStorage.get(uuid).getTab(12).id).toBe(12); 120 | expect(newWorkspaceStorage.get(uuid).getTab(12).index).toBe(0); 121 | expect(newWorkspaceStorage.get(uuid).lastUpdated).toBe(now); 122 | expect(newWorkspaceStorage.get(uuid)).toEqual(workspace); 123 | expect(newWorkspaceStorage.get(workspace.windowId)).toEqual(workspace); 124 | }); 125 | 126 | test('should handle serialization and deserialization of empty storage', () => { 127 | const serialized = workspaceStorage.serialize(); 128 | const newWorkspaceStorage = new WorkspaceStorage(); 129 | newWorkspaceStorage.deserialize(serialized); 130 | 131 | expect(newWorkspaceStorage.size).toBe(0); 132 | }); 133 | }); -------------------------------------------------------------------------------- /src/types/html.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.html" { 2 | const content: string; 3 | export default content; 4 | } -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { TabGroupStub } from "./obj/tab-group-stub"; 2 | import { TabStub } from "./obj/tab-stub"; 3 | import { Workspace } from "./obj/workspace"; 4 | import { StorageHelper } from "./storage-helper"; 5 | 6 | /** 7 | * Utility class containing various helper methods. 8 | */ 9 | export class Utils { 10 | /** 11 | * Retrieves a Chrome window by its ID. 12 | * @param windowId - The ID of the window to retrieve. 13 | * @returns A Promise that resolves with the retrieved window, or undefined if not found. 14 | */ 15 | public static async getWindowById(windowId: number): Promise { 16 | return new Promise((resolve) => { 17 | chrome.windows.get(windowId).then((window) => { 18 | resolve(window); 19 | }) 20 | // If the window is not found, the promise will still resolve, just with an undefined value. 21 | // Catch the error to prevent the console from logging it. 22 | .catch(() => { 23 | resolve(undefined); 24 | }); 25 | }); 26 | } 27 | 28 | /** 29 | * Retrieves all Chrome window IDs. 30 | * @returns A Promise that resolves with an array of window IDs. 31 | */ 32 | public static async getAllWindowIds(): Promise<(number | undefined)[]> { 33 | return new Promise((resolve) => { 34 | chrome.windows.getAll().then((windows) => { 35 | const windowIds = windows.map((window) => window.id); 36 | resolve(windowIds); 37 | }) 38 | // If the windows are not found, the promise will still resolve, just with an empty array. 39 | // Catch the error to prevent the console from logging it. 40 | .catch(() => { 41 | resolve([]); 42 | }); 43 | }); 44 | } 45 | 46 | /** 47 | * Focuses a Chrome window by its ID. 48 | * @param windowId - The ID of the window to focus. 49 | */ 50 | public static async focusWindow(windowId: number): Promise { 51 | chrome.windows.update(windowId, { focused: true }); 52 | } 53 | 54 | /** 55 | * Sets the extension badge text for all tabs in a window. 56 | * 57 | * There are only two options for badge text: per tab and globally. We can't set the badge text for a window, 58 | * so we have to set it for each tab in the window. 59 | * 60 | * This is called every time the tabs are saved to workspace storage, which is common enough that it will 61 | * always be up to date. 62 | */ 63 | public static async setBadgeForWindow(windowId: number, text: string, color?: string | chrome.action.ColorArray): Promise { 64 | const tabs = await this.getTabsFromWindow(windowId); 65 | for (const tab of tabs) { 66 | this.setBadgeForTab(text, tab.id, color); 67 | } 68 | } 69 | 70 | /** 71 | * Clears the extension badge text for all tabs in a window. 72 | */ 73 | public static async clearBadgeForWindow(windowId: number): Promise { 74 | const tabs = await this.getTabsFromWindow(windowId); 75 | for (const tab of tabs) { 76 | this.setBadgeForTab("", tab.id); 77 | } 78 | } 79 | 80 | /** 81 | * Sets the extension badge text for a tab. 82 | */ 83 | public static async setBadgeForTab(text: string, tabId?: number, color?: string | chrome.action.ColorArray): Promise { 84 | chrome.action.setBadgeText({ text: text, tabId: tabId }); 85 | if (color !== undefined) { 86 | chrome.action.setBadgeBackgroundColor({ color: color, tabId: tabId }); 87 | } 88 | } 89 | 90 | /** 91 | * Retrieve all the tabs from an open workspace window. 92 | * @param windowId - The ID of the window to retrieve tabs from. 93 | */ 94 | public static async getTabsFromWindow(windowId: number): Promise { 95 | return chrome.tabs.query({ windowId: windowId }); 96 | } 97 | 98 | /** 99 | * Retrieve all the tab groups from a window. 100 | */ 101 | public static async getTabGroupsFromWindow(windowId: number): Promise { 102 | return chrome.tabGroups.query({ windowId: windowId }); 103 | } 104 | 105 | /** 106 | * Sets the tabs of a workspace and saves it to storage. 107 | * @param workspace - The workspace to update. 108 | * @param tabs - The tabs to set for the workspace. 109 | * @returns - A promise that resolves when the workspace is updated and saved. 110 | */ 111 | public static async setWorkspaceTabs(workspace: Workspace, tabs: chrome.tabs.Tab[]): Promise { 112 | 113 | workspace.setTabs(TabStub.fromTabs(tabs)); 114 | await StorageHelper.setWorkspace(workspace); 115 | } 116 | 117 | /** 118 | * Sets the tabs and tab groups of a workspace and saves it to storage. 119 | * @param workspace - The workspace to update. 120 | * @param tabs - The tabs to set for the workspace. 121 | * @param tabGroups - The tab groups to set for the workspace. 122 | * @returns - A promise that resolves when the workspace is updated and saved. 123 | */ 124 | public static async setWorkspaceTabsAndGroups(workspace: Workspace, tabs: chrome.tabs.Tab[], tabGroups?: chrome.tabGroups.TabGroup[]): Promise { 125 | console.log(`Setting tabs and groups: `, tabs, tabGroups); 126 | workspace.setTabs(TabStub.fromTabs(tabs)); 127 | if (tabGroups !== undefined) { 128 | workspace.setTabGroups(TabGroupStub.fromTabGroups(tabGroups)); 129 | } 130 | await StorageHelper.setWorkspace(workspace); 131 | } 132 | 133 | /** 134 | * Checks if the code is running in a Jest test environment. 135 | * @returns A boolean indicating whether the code is running in a Jest test environment. 136 | */ 137 | public static areWeTestingWithJest(): boolean { 138 | if (typeof process === 'undefined') 139 | return false; 140 | 141 | return process.env.JEST_WORKER_ID !== undefined; 142 | } 143 | 144 | /** 145 | * Interpolates variables into a template string. 146 | * @param template - The template string containing placeholders. 147 | * @param variables - An object containing the variables to be interpolated. 148 | * @returns The interpolated string. 149 | */ 150 | public static interpolateTemplate(template: string, variables: Record): string { 151 | return template.replace(/\$\{(\w+)\}/g, (_, variable) => String(variables[variable])); 152 | } 153 | 154 | /** 155 | * Determine if the given tab URL is one we don't want to keep track of, or if it's special and we should track it. 156 | * @param url - The URL of the tab. If undefined, the URL is considered trackable. 157 | */ 158 | public static isUrlUntrackable(url: string | undefined): boolean { 159 | if (url === undefined || url.length == 0) 160 | return true; 161 | return url.startsWith("chrome://") || url.startsWith("about:") || url.startsWith("chrome-extension://"); 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /src/utils/chunk.ts: -------------------------------------------------------------------------------- 1 | 2 | export class ChunkUtil { 3 | /** 4 | * Chunks an array into smaller arrays, each with a maximum byte size. 5 | * @param array - The array to chunk. 6 | * @param maxBytes - The maximum byte size for each chunk. 7 | */ 8 | public static chunkArray(array: T[], maxBytes: number): T[][] { 9 | const chunks: T[][] = []; 10 | let currentChunk: T[] = []; 11 | let currentChunkSize = 0; 12 | 13 | for (const item of array) { 14 | const itemSize = new Blob([JSON.stringify(item)]).size; 15 | if (currentChunkSize + itemSize >= maxBytes) { 16 | chunks.push(currentChunk); 17 | currentChunk = []; 18 | currentChunkSize = 0; 19 | } 20 | currentChunk.push(item); 21 | currentChunkSize += itemSize; 22 | } 23 | 24 | if (currentChunk.length > 0) { 25 | chunks.push(currentChunk); 26 | } 27 | 28 | return chunks; 29 | } 30 | 31 | /** 32 | * Combines smaller arrays back into a single array. 33 | * 34 | * Note: This method assumes that the chunks are in the correct order, and 35 | * that none of the array elements are null or undefined. 36 | * @param chunks - The array of chunks to combine. 37 | * @returns The combined array. 38 | */ 39 | public static unChunkArray(chunks: T[][]): T[] { 40 | const combinedArray: T[] = []; 41 | for (const chunk of chunks) { 42 | combinedArray.push(...chunk); 43 | } 44 | return combinedArray; 45 | } 46 | } -------------------------------------------------------------------------------- /src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | import { Utils } from "../utils"; 2 | 3 | export class DebounceUtil { 4 | private static debounceTimeouts: Map = new Map(); 5 | 6 | /** 7 | * Debounce the execution of a callback. 8 | * @param id - A unique identifier for the callback. 9 | * @param callback - The callback function to debounce. 10 | * @param delay - The delay in ms to debounce the callback. 11 | */ 12 | public static debounce(id: string, callback: () => void, delay: number): void { 13 | if (DebounceUtil.debounceTimeouts.has(id)) { 14 | clearTimeout(DebounceUtil.debounceTimeouts.get(id)); 15 | } 16 | 17 | // If we're testing with Jest, don't debounce, since we probably test the function immediately. 18 | if (Utils.areWeTestingWithJest()) { 19 | callback(); 20 | return; 21 | } 22 | 23 | const timeout = setTimeout(() => { 24 | callback(); 25 | DebounceUtil.debounceTimeouts.delete(id); 26 | }, delay); 27 | 28 | DebounceUtil.debounceTimeouts.set(id, timeout); 29 | } 30 | 31 | /** 32 | * Clear the debounce timeout for a callback. 33 | * @param id - The unique identifier for the callback. 34 | * @returns True if the debounce was cleared, false if it was not found. 35 | */ 36 | public static clearDebounce(id: string): boolean { 37 | if (DebounceUtil.debounceTimeouts.has(id)) { 38 | clearTimeout(DebounceUtil.debounceTimeouts.get(id)); 39 | DebounceUtil.debounceTimeouts.delete(id); 40 | return true; 41 | } 42 | return false; 43 | } 44 | } -------------------------------------------------------------------------------- /src/utils/feature-detect.ts: -------------------------------------------------------------------------------- 1 | 2 | export class FeatureDetect { 3 | public static supportsTabGroups(): boolean { 4 | return typeof chrome.tabGroups !== 'undefined'; 5 | } 6 | } -------------------------------------------------------------------------------- /src/utils/prompt.ts: -------------------------------------------------------------------------------- 1 | import { BaseDialog } from "../dialogs/base-dialog"; 2 | import DIALOG_TEMPLATE from "../templates/dialogPopupTemplate.html"; 3 | import { Utils } from "../utils"; 4 | 5 | export class Prompt extends BaseDialog { 6 | /** 7 | * A utility function that creates a dialog prompt and returns a Promise. 8 | * The Promise resolves with the input value when the form is submitted, 9 | * and resolves with null when the cancel button is clicked. 10 | * 11 | * @param dialogTemplate - The template for the dialog. 12 | * @param formSelector - The selector for the form element. 13 | * @param inputSelector - The selector for the input element. 14 | * @param cancelSelector - The selector for the cancel button. 15 | * @returns A Promise that resolves with the user's input value or null if canceled. 16 | */ 17 | public static createPrompt( 18 | promptTitle: string, 19 | formSelector: string = "#modal-form", 20 | inputSelector: string = "#modal-input-name", 21 | cancelSelector: string = "#modal-cancel", 22 | dialogTemplate: string = DIALOG_TEMPLATE 23 | ): Promise { 24 | return new Promise((resolve, reject) => { 25 | const dialog = Utils.interpolateTemplate(dialogTemplate, {prompt: promptTitle}); 26 | 27 | const tempDiv = document.createElement('div'); 28 | tempDiv.innerHTML = dialog; 29 | const dialogElement = tempDiv.firstElementChild as HTMLDialogElement; 30 | 31 | const formElement = dialogElement.querySelector(formSelector) as HTMLFormElement; 32 | const inputElement = dialogElement.querySelector(inputSelector) as HTMLInputElement; 33 | const cancelButton = dialogElement.querySelector(cancelSelector) as HTMLButtonElement; 34 | 35 | if (!formElement || !inputElement || !cancelButton) { 36 | reject('Missing required elements'); 37 | return; 38 | } 39 | 40 | formElement.addEventListener("submit", (e) => { 41 | e.preventDefault(); 42 | const inputValue = inputElement.value; 43 | dialogElement.close(); 44 | resolve(inputValue); 45 | // Remove the dialog from the DOM or it will persist after closing 46 | dialogElement.remove(); 47 | }); 48 | 49 | // If the dialog is closed without submitting the form, resolve with null 50 | dialogElement.addEventListener("cancel", () => { 51 | BaseDialog.cancelCloseDialog(dialogElement, resolve); 52 | }); 53 | 54 | cancelButton.addEventListener("click", () => { 55 | BaseDialog.cancelCloseDialog(dialogElement, resolve); 56 | }); 57 | 58 | document.body.appendChild(dialogElement); 59 | dialogElement.showModal(); 60 | }); 61 | } 62 | 63 | public open(): void { 64 | throw new Error("Method not implemented."); 65 | } 66 | } -------------------------------------------------------------------------------- /src/utils/tab-utils.ts: -------------------------------------------------------------------------------- 1 | import { TabStub } from "../obj/tab-stub"; 2 | 3 | export class TabUtils { 4 | 5 | /** 6 | * Update the TabStub objects with the new tab ids. 7 | * 8 | * Associating the TabStub objects with the new tab ids has some complexities. 9 | * See `Workspace.ensureTabIndexesAreOrdered()` for more details. 10 | * 11 | * For this method we assume that the tabs are in the correct order in the new window and that the 12 | * indexes are correct. 13 | * 14 | * @param workspaceTabs - The tabs from the workspace. 15 | * @param newWindowTabs - The tabs from the newly created window. 16 | */ 17 | public static updateTabStubIdsFromTabs(workspaceTabs: TabStub[], newWindowTabs: chrome.tabs.Tab[]) { 18 | console.debug("Updating tab stub ids", workspaceTabs, newWindowTabs); 19 | for (let i = 0; i < workspaceTabs.length; i++) { 20 | const workspaceTab = workspaceTabs[i]; 21 | const newWindowTab = newWindowTabs[i]; 22 | // const newWindowTab = newWindowTabs.find(tab => tab.index === i && tab.url === workspaceTab.url); 23 | if (newWindowTab && newWindowTab.id !== undefined) { 24 | workspaceTab.id = newWindowTab.id; 25 | } 26 | else { 27 | console.warn(`Could not find a matching tab for workspace tab ${workspaceTab}`); 28 | } 29 | } 30 | } 31 | 32 | /** 33 | * Update the newly created chrome.tabs to match the workspace tabs extra data (active, pinned, etc). 34 | * The window should be created with the tabs in the correct order, so loop through the tabs 35 | * and use chrome.tabs.update to update the tabs. 36 | * 37 | * TODO: Looks the pinned update isn't working properly. Investigate. 38 | * 39 | * @param workspaceTabs - The tabs from the workspace. 40 | * @returns A promise that resolves when all the tabs have been updated. 41 | */ 42 | public static async updateNewWindowTabsFromTabStubs(workspaceTabs: TabStub[]): Promise { 43 | console.debug("Updating new window tabs", workspaceTabs); 44 | for (let i = 0; i < workspaceTabs.length; i++) { 45 | const tab = workspaceTabs[i]; 46 | await chrome.tabs.update(tab.id, { 47 | active: tab.active, 48 | pinned: tab.pinned, 49 | muted: tab.mutedInfo?.muted 50 | }); 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /src/utils/workspace-utils.ts: -------------------------------------------------------------------------------- 1 | import { Workspace } from "../obj/workspace"; 2 | import { SyncWorkspaceStorage, SyncWorkspaceTombstone } from "../storage/sync-workspace-storage"; 3 | import { WorkspaceStorage } from '../workspace-storage'; 4 | 5 | /** 6 | * Utility class for workspace operations. 7 | */ 8 | export class WorkspaceUtils { 9 | /** 10 | * Instead of marking workspaces as deleted, we can replace them with a lightweight "tombstone" object that contains only the workspace ID and a deletion timestamp. 11 | * This way, the sync storage would only store active workspaces and tombstones, which are much smaller in size than the actual workspace data. 12 | * 13 | * During synchronization, machines would check for tombstones and remove the corresponding workspaces from their local storage, if they exist. 14 | * In the case of a conflict during deletion, the user will be prompted to choose whether to keep the workspace or delete it. 15 | * 16 | * @param conflictResolver - A function that resolves conflicts between local workspaces and tombstones. Return true to keep the workspace, false to delete it. 17 | * @returns The updated local and sync storage instances, and a list of UUIDs that need to be deleted from the sync storage. 18 | */ 19 | public static syncWorkspaces(localStorage: WorkspaceStorage, 20 | syncStorage: WorkspaceStorage, 21 | syncStorageTombstones: SyncWorkspaceTombstone[], 22 | conflictResolver: (localWorkspace: Workspace, tombstone: SyncWorkspaceTombstone) => boolean 23 | ): [WorkspaceStorage, WorkspaceStorage, string[]] { 24 | const toDeleteFromSyncStorage: string[] = []; 25 | 26 | // We need to check for tombstones and do local deletion before merging the workspaces, otherwise we will end up with deleted workspaces in the merged storage. 27 | syncStorageTombstones.forEach(tombstone => { 28 | if (localStorage.has(tombstone.uuid)) { 29 | const localWorkspace = localStorage.get(tombstone.uuid); 30 | 31 | if (localWorkspace != undefined && localWorkspace?.lastUpdated != undefined) { 32 | 33 | // If the local workspace was last updated before the tombstone was deleted, we can safely delete it. 34 | if (localWorkspace.lastUpdated <= tombstone.timestamp) { 35 | console.debug(`Deleting workspace ${localWorkspace.name} (${ tombstone.uuid }) from local storage.`); 36 | localStorage.delete(tombstone.uuid); 37 | 38 | if(syncStorage.has(tombstone.uuid)) { 39 | syncStorage.delete(tombstone.uuid); 40 | toDeleteFromSyncStorage.push(tombstone.uuid); 41 | } 42 | } 43 | else { 44 | // If the local workspace was updated after the tombstone was deleted, we need to prompt the user 45 | console.warn(`Conflict detected for workspace ${ localWorkspace.name } (${ tombstone.uuid }).`); 46 | const keepWorkspace = conflictResolver(localWorkspace, tombstone); 47 | if (keepWorkspace) { 48 | console.debug(`Keeping workspace ${ tombstone.uuid } in local storage.`); 49 | } 50 | else { 51 | console.debug(`Deleting workspace ${ tombstone.uuid } from local storage.`); 52 | localStorage.delete(tombstone.uuid); 53 | } 54 | } 55 | } 56 | else if (localWorkspace?.lastUpdated == undefined) { 57 | // Local workspace is missing a last updated time, we can safely delete it. 58 | console.debug(`Deleting workspace ${ tombstone.uuid } from local storage with no lastUpdated.`); 59 | localStorage.delete(tombstone.uuid); 60 | } 61 | } 62 | }); 63 | 64 | const allKeys = new Set([...localStorage.keys(), ...syncStorage.keys()]); 65 | 66 | allKeys.forEach(key => { 67 | const localWorkspace = localStorage.get(key); 68 | const syncWorkspace = syncStorage.get(key); 69 | 70 | if (localWorkspace && syncWorkspace) { 71 | if (SyncWorkspaceStorage.getMoreRecentWorkspace(localWorkspace, syncWorkspace) === localWorkspace) { 72 | syncStorage.set(key, localWorkspace); 73 | } 74 | else { 75 | localStorage.set(key, syncWorkspace); 76 | } 77 | } 78 | else if (localWorkspace) { 79 | syncStorage.set(key, localWorkspace); 80 | } 81 | else if (syncWorkspace) { 82 | localStorage.set(key, syncWorkspace); 83 | } 84 | }); 85 | 86 | return [localStorage, syncStorage, toDeleteFromSyncStorage]; 87 | } 88 | } -------------------------------------------------------------------------------- /src/workspace-entry-logic.ts: -------------------------------------------------------------------------------- 1 | import { Workspace } from "./obj/workspace"; 2 | import { PopupActions } from "./popup-actions"; 3 | import { Prompt } from "./utils/prompt"; 4 | import WORKSPACE_TEMPLATE from "./templates/workspaceElemTemplate.html"; 5 | import { Utils } from "./utils"; 6 | import { WorkspaceStorage } from "./workspace-storage"; 7 | 8 | /** 9 | * Logic for the workspace entry in the popup. 10 | */ 11 | export class WorkspaceEntryLogic { 12 | 13 | public static listWorkspaces(workspaces: WorkspaceStorage, allWindowIds: (number | undefined)[]) { 14 | console.debug("listWorkspaces", workspaces) 15 | 16 | const workspaceDiv = document.getElementById("workspaces-list"); 17 | if (!workspaceDiv) { 18 | console.error("Could not find workspace div"); 19 | return; 20 | } 21 | workspaceDiv.innerHTML = ""; 22 | 23 | // Add each workspace to the list, and add event listeners to the buttons. 24 | for (const workspace of Array.from(workspaces.values())) { 25 | const workspaceElement = this.addWorkspace(workspaceDiv, workspace); 26 | const openWorkspace = workspaceElement.querySelector('.workspace-item-interior'); 27 | // const settingsWorkspace = workspaceElement.querySelector('#settings-button'); 28 | const editWorkspace = workspaceElement.querySelector('#edit-button'); 29 | const deleteWorkspace = workspaceElement.querySelector('#delete-button'); 30 | 31 | // Add a class to the workspace if it is open in a window. 32 | if (allWindowIds.includes(workspace.windowId)) { 33 | workspaceElement.classList.add('workspace-open'); 34 | } 35 | openWorkspace?.addEventListener('click', () => { 36 | this.workspaceClicked(workspace); 37 | }); 38 | 39 | editWorkspace?.addEventListener('click', (event) => { 40 | event.stopPropagation(); 41 | this.workspaceEditClicked(workspace); 42 | }); 43 | 44 | deleteWorkspace?.addEventListener('click', (event) => { 45 | event.stopPropagation(); 46 | this.workspaceDeleteClicked(workspace); 47 | }); 48 | } 49 | } 50 | 51 | /** 52 | * Adds a workspace list item to the parent node. 53 | * @param parentNode - The parent node to which the workspace will be added. 54 | * @param workspace - The workspace object. 55 | */ 56 | private static addWorkspace(parentNode: HTMLElement, workspace: Workspace): HTMLElement { 57 | const res = Utils.interpolateTemplate(WORKSPACE_TEMPLATE, { 58 | workspaceName: workspace.name, workspaceId: workspace.windowId, 59 | tabsCount: workspace.getTabs().length 60 | }); 61 | const tempDiv = document.createElement('div'); 62 | tempDiv.innerHTML = res; 63 | const workspaceElement = tempDiv.firstElementChild as HTMLElement; 64 | 65 | if (parentNode instanceof Node && workspaceElement != null) { 66 | parentNode.appendChild(workspaceElement); 67 | return workspaceElement; 68 | } 69 | else { 70 | throw new Error("parentNode must be a valid DOM node"); 71 | } 72 | } 73 | 74 | /** 75 | * Called when a workspace is clicked. 76 | * @param workspaceId - 77 | */ 78 | public static workspaceClicked(workspace: Workspace) { 79 | PopupActions.openWorkspace(workspace); 80 | } 81 | 82 | /** 83 | * Called when a workspace's settings button is clicked. 84 | * @param workspaceId - 85 | */ 86 | public static workspaceSettingsClicked(workspace: Workspace) { 87 | console.debug("workspaceSettingsClicked", workspace); 88 | // Actions.openWorkspaceSettings(workspace.uuid); 89 | } 90 | 91 | /** 92 | * Called when a workspace's edit button is clicked. 93 | * 94 | * Start the process of renaming the workspace: 95 | * 1. Prompt the user for a new name. 96 | * 2. Send a message to the background script to rename the workspace. 97 | * 3. Update the workspace list. 98 | * @param workspaceId - 99 | */ 100 | public static async workspaceEditClicked(workspace: Workspace) { 101 | console.debug("workspaceEditClicked", workspace); 102 | const newName = await Prompt.createPrompt("Enter a new name for the workspace"); 103 | if (newName === null || newName === "" || newName === workspace.name) { 104 | console.info("User canceled or entered the same name"); 105 | return; 106 | } 107 | PopupActions.renameWorkspace(workspace, newName); 108 | } 109 | 110 | /** 111 | * Called when a workspace's delete button is clicked. 112 | * @param workspaceId - 113 | */ 114 | public static workspaceDeleteClicked(workspace: Workspace) { 115 | console.debug("workspaceDeleteClicked", workspace) 116 | // Verify the user wants to delete the workspace. 117 | if (!confirm(`Are you sure you want to delete the workspace "${ workspace.name }"?`)) { 118 | return; 119 | } 120 | PopupActions.deleteWorkspace(workspace); 121 | } 122 | } -------------------------------------------------------------------------------- /src/workspace-storage.ts: -------------------------------------------------------------------------------- 1 | import { IWorkspaceJson } from "./interfaces/i-workspace-json"; 2 | import { Workspace } from './obj/workspace'; 3 | 4 | /** 5 | * Represents a storage for workspaces, implementing the Map interface. 6 | * 7 | * The keys are either the workspace uuid or the workspace's windowId. 8 | */ 9 | export class WorkspaceStorage implements Map { 10 | private workspaces: Map; 11 | private windowIdToUuid: Map; 12 | 13 | [Symbol.toStringTag] = 'WorkspaceStorage' as const; // Initialize the property 14 | 15 | constructor() { 16 | this.workspaces = new Map(); 17 | this.windowIdToUuid = new Map(); 18 | } 19 | 20 | // #region Map implementation 21 | get size(): number { 22 | return this.workspaces.size; 23 | } 24 | 25 | clear(): void { 26 | this.workspaces.clear(); 27 | this.windowIdToUuid.clear(); 28 | } 29 | 30 | delete(key: string | number): boolean { 31 | if (typeof key === 'string') { 32 | const workspace = this.workspaces.get(key); 33 | if (workspace) { 34 | this.windowIdToUuid.delete(workspace.windowId); 35 | return this.workspaces.delete(key); 36 | } 37 | } else { 38 | const uuid = this.windowIdToUuid.get(key); 39 | if (uuid) { 40 | this.workspaces.delete(uuid); 41 | return this.windowIdToUuid.delete(key); 42 | } 43 | } 44 | return false; 45 | } 46 | 47 | forEach(callbackfn: (value: Workspace, key: string | number, map: Map) => void, thisArg?: unknown): void { 48 | this.workspaces.forEach((value, key) => { 49 | callbackfn.call(thisArg, value, key, this); 50 | }); 51 | } 52 | 53 | get(key: string | number): Workspace | undefined { 54 | if (typeof key === 'string') { 55 | return this.workspaces.get(key); 56 | } else { 57 | const uuid = this.windowIdToUuid.get(key); 58 | return uuid ? this.workspaces.get(uuid) : undefined; 59 | } 60 | } 61 | 62 | has(key: string | number): boolean { 63 | if (typeof key === 'string') { 64 | return this.workspaces.has(key); 65 | } else { 66 | return this.windowIdToUuid.has(key) && this.workspaces.has(this.windowIdToUuid.get(key) as string); 67 | } 68 | } 69 | 70 | /** 71 | * Sets a workspace in the storage. 72 | * 73 | * @param key - The key to associate with the workspace. Can be either the workspace uuid or the workspace's windowId. 74 | * @param value - The workspace to be stored. 75 | * @returns The updated instance of the workspace storage. 76 | */ 77 | set(key: string | number, value: Workspace): this { 78 | if (typeof key === 'string') { 79 | this.workspaces.set(key, value); 80 | this.windowIdToUuid.set(value.windowId, key); 81 | } else { 82 | this.workspaces.set(value.uuid, value); 83 | this.windowIdToUuid.set(key, value.uuid); 84 | } 85 | return this; 86 | } 87 | 88 | [Symbol.iterator](): IterableIterator<[string | number, Workspace]> { 89 | return this.entries(); 90 | } 91 | 92 | entries(): IterableIterator<[string | number, Workspace]> { 93 | const entries: [string | number, Workspace][] = []; 94 | this.workspaces.forEach((workspace, uuid) => { 95 | entries.push([uuid, workspace]); 96 | }); 97 | return entries[Symbol.iterator](); 98 | } 99 | 100 | keys(): IterableIterator { 101 | const keys: (string | number)[] = []; 102 | this.workspaces.forEach((_workspace, uuid) => { 103 | keys.push(uuid); 104 | }); 105 | return keys[Symbol.iterator](); 106 | } 107 | 108 | values(): IterableIterator { 109 | return this.workspaces.values(); 110 | } 111 | // #endregion 112 | 113 | // #region Serialization 114 | serialize(): string { 115 | const workspacesArray: [string, object][] = []; 116 | this.workspaces.forEach((workspace, uuid) => { 117 | workspacesArray.push([uuid, workspace.toJsonObject()]); 118 | }); 119 | const windowIdToUuidArray = Array.from(this.windowIdToUuid.entries()); 120 | return JSON.stringify({ workspaces: workspacesArray, windowIdToUuid: windowIdToUuidArray }); 121 | } 122 | 123 | deserialize(serialized: string): void { 124 | const data = JSON.parse(serialized); 125 | // This turns them into maps, but they're still just data objects, not Workspace objects. 126 | const workspaces = new Map(data.workspaces); 127 | 128 | // Convert the workspaces to Workspace objects, and set them in the storage. 129 | // This also sets the windowIdToUuid map. 130 | workspaces.forEach((workspace, uuid) => { 131 | this.set(uuid as string, Workspace.fromJson(workspace as IWorkspaceJson)); 132 | }); 133 | } 134 | // #endregion 135 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | // "target": "es6", 5 | "downlevelIteration": true, 6 | // "moduleResolution": "bundler", 7 | // "module": "ES6", 8 | "esModuleInterop": true, 9 | "rootDir": "src", 10 | "allowJs": true, 11 | "outDir": "build", 12 | "noEmitOnError": true, 13 | "typeRoots": [ "node_modules/@types" ], 14 | "sourceMap": true, 15 | }, 16 | "include": [ 17 | "src/**/*" 18 | ], 19 | } 20 | 21 | // { 22 | // "compilerOptions": { 23 | // "esModuleInterop": true, 24 | // "allowJs": true, 25 | // "outDir": "build", 26 | // } 27 | // } -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | } 5 | } --------------------------------------------------------------------------------