diff --git a/README.md b/README.md index 2d598be..0ad7144 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This extension aims to be an alternative to [Tab Groups](https://addons.mozilla. - If you have multiple windows open, each one has its own set of workspaces. - Send a specific tab to another workspace from the right-click menu. - Press Ctrl+E to open the list of workspaces, then press 1-9 to switch between using keyboard shortcuts. + - Search through your tabs in the address bar. Type "ws [text]" to begin searching. Choose a result to switch to that tab. ## Notice There is no way to "hide" a tab with the WebExtensions API, so when switching between workspaces the tabs are actually closed and reopened. @@ -20,12 +21,6 @@ This has the side effect of not maintaining the tabs' history, as well as stoppi If you know any better way to hide the tabs, please let me know. -# Future Improvements -I'm planning to add the following features in the near future: - -- UI improvements -- Suggest tabs from other workspaces in the awesomebar - ## Acknowledgements This extension was inspired by [Multi-Account Containers](https://addons.mozilla.org/en-US/firefox/addon/multi-account-containers/), which also served as a reference for some of the functionality. diff --git a/background/backgroundLogic.js b/background/backgroundLogic.js index d224176..0f617b3 100644 --- a/background/backgroundLogic.js +++ b/background/backgroundLogic.js @@ -13,6 +13,12 @@ const BackgroundLogic = { BackgroundLogic.updateContextMenu(); } }); + + browser.tabs.onCreated.addListener(BackgroundLogic.updateContextMenu); + browser.tabs.onRemoved.addListener(BackgroundLogic.updateContextMenu); + + browser.omnibox.onInputChanged.addListener(BackgroundLogic.handleAwesomebarSearch); + browser.omnibox.onInputEntered.addListener(BackgroundLogic.handleAwesomebarSelection); }, async getWorkspacesForCurrentWindow(){ @@ -67,9 +73,9 @@ const BackgroundLogic = { // Since we're gonna be closing all open tabs, we need to show the new ones first. // However, we first need to prepare the old one, so it can tell which tabs were the original ones and which were opened by the new workspace. - await oldWorkspace.prepareToHide(windowId); - await newWorkspace.show(windowId); - await oldWorkspace.hide(windowId); + await oldWorkspace.prepareToHide(); + await newWorkspace.show(); + await oldWorkspace.hide(); }, async renameWorkspace(workspaceId, workspaceName) { @@ -91,7 +97,7 @@ const BackgroundLogic = { await BackgroundLogic.switchToWorkspace(nextWorkspaceId); } - await workspaceToDelete.delete(windowId); + await workspaceToDelete.delete(); // Re-render context menu BackgroundLogic.updateContextMenu(); @@ -141,9 +147,10 @@ const BackgroundLogic = { }); const workspaces = await BackgroundLogic.getWorkspacesForCurrentWindow(); - workspaces.forEach(workspace => { + const workspaceObjects = await Promise.all(workspaces.map(workspace => workspace.toObject())); + workspaceObjects.forEach(workspace => { browser.menus.create({ - title: workspace.name, + title: `${workspace.name} (${workspace.tabCount} tabs)`, parentId: menuId, id: workspace.id, enabled: !workspace.active, @@ -151,6 +158,11 @@ const BackgroundLogic = { }); }); + browser.menus.create({ + parentId: menuId, + type: "separator" + }); + browser.menus.create({ title: "Create new workspace", parentId: menuId, @@ -174,6 +186,60 @@ const BackgroundLogic = { } await BackgroundLogic.moveTabToWorkspace(tab, destinationWorkspace); + }, + + async handleAwesomebarSearch(text, suggest){ + suggest(await BackgroundLogic.searchTabs(text)); + }, + + async handleAwesomebarSelection(content, disposition){ + let windowId, workspaceId, tabIndex; + [windowId, workspaceId, tabIndex] = content.split(':'); + + await browser.windows.update(parseInt(windowId), {focused: true}); + + const workspace = await Workspace.find(workspaceId); + await BackgroundLogic.switchToWorkspace(workspace.id); + + const matchedTabs = await browser.tabs.query({ + windowId: parseInt(windowId), + index: parseInt(tabIndex) + }); + + if (matchedTabs.length > 0){ + await browser.tabs.update(matchedTabs[0].id, {active: true}); + } + }, + + async searchTabs(text){ + if (text.length < 3){ + return []; + } + + const windows = await browser.windows.getAll({windowTypes: ['normal']}) + const promises = windows.map(windowInfo => BackgroundLogic.searchTabsInWindow(text, windowInfo.id)); + + return Util.flattenArray(await Promise.all(promises)); + }, + + async searchTabsInWindow(text, windowId){ + const suggestions = []; + + const workspaces = await BackgroundLogic.getWorkspacesForWindow(windowId); + const promises = workspaces.map(async workspace => { + const tabs = await workspace.getTabs(); + tabs.forEach(tab => { + if (Util.matchesQuery(tab.title, text)) { + suggestions.push({ + content: `${windowId}:${workspace.id}:${tab.index}`, + description: tab.title + }); + } + }); + }); + + await Promise.all(promises); + return suggestions; } }; diff --git a/background/messageHandler.js b/background/messageHandler.js index 835493e..73dcf2a 100644 --- a/background/messageHandler.js +++ b/background/messageHandler.js @@ -3,17 +3,22 @@ browser.runtime.onMessage.addListener(async m => { switch (m.method) { case "getWorkspacesForCurrentWindow": - response = BackgroundLogic.getWorkspacesForCurrentWindow(); + const workspaces = await BackgroundLogic.getWorkspacesForCurrentWindow(); + response = await Promise.all(workspaces.map(workspace => workspace.toObject())); break; + case "switchToWorkspace": await BackgroundLogic.switchToWorkspace(m.workspaceId); break; + case "createNewWorkspaceAndSwitch": await BackgroundLogic.createNewWorkspaceAndSwitch(); break; + case "renameWorkspace": await BackgroundLogic.renameWorkspace(m.workspaceId, m.workspaceName); break; + case "deleteWorkspace": await BackgroundLogic.deleteWorkspace(m.workspaceId); break; diff --git a/background/util.js b/background/util.js index 9dc3e1e..19c9456 100644 --- a/background/util.js +++ b/background/util.js @@ -17,6 +17,19 @@ const Util = { ) }, + matchesQuery(subject, query){ + return query.split(" ") + .filter(token => token) + .every(token => subject.toLowerCase().indexOf(token.toLowerCase()) != -1); + }, + + flattenArray(arr) { + return arr.reduce( + (acc, cur) => acc.concat(cur), + [] + ); + }, + // From https://gist.github.com/nmsdvid/8807205 debounce(func, wait, immediate) { var timeout; diff --git a/background/workspace.js b/background/workspace.js index e4cccaa..2b335b8 100644 --- a/background/workspace.js +++ b/background/workspace.js @@ -1,13 +1,23 @@ class Workspace { - constructor(id, name, active, hiddenTabs) { + constructor(id, state) { this.id = id; - this.name = name; - this.active = active; - this.hiddenTabs = hiddenTabs; + + if (state){ + this.name = state.name; + this.active = state.active; + this.hiddenTabs = state.hiddenTabs; + this.windowId = state.windowId; + } } static async create(windowId, name, active) { - const workspace = new Workspace(Util.generateUUID(), name, active || false, []); + const workspace = new Workspace(Util.generateUUID(), { + name: name, + active: active || false, + hiddenTabs: [], + windowId: windowId + }); + await workspace.storeState(); await WorkspaceStorage.registerWorkspaceToWindow(windowId, workspace.id); @@ -26,30 +36,50 @@ class Workspace { await this.storeState(); } + async getTabs() { + if (this.active){ + // Not counting pinned tabs. Should we? + const tabs = await browser.tabs.query({ + pinned: false, + windowId: this.windowId + }); + + return tabs; + } else { + return this.hiddenTabs; + } + } + + async toObject() { + const obj = Object.assign({}, this); + obj.tabCount = (await this.getTabs()).length; + + return obj; + } + // Store hidden tabs in storage - async prepareToHide(windowId) { + async prepareToHide() { const tabs = await browser.tabs.query({ - windowId: windowId, + windowId: this.windowId, pinned: false }); tabs.forEach(tab => { - const tabObject = Object.assign({}, tab); - this.hiddenTabs.push(tabObject); + this.hiddenTabs.push(tab); }) } // Then remove the tabs from the window - async hide(windowId) { + async hide() { this.active = false; await this.storeState(); const tabIds = this.hiddenTabs.map(tab => tab.id); - browser.tabs.remove(tabIds); + await browser.tabs.remove(tabIds); } - async show(windowId) { - const tabs = this.hiddenTabs.filter(tabObject => Util.isPermissibleURL(tabObject.url)); + async show() { + const tabs = this.hiddenTabs.filter(tab => Util.isPermissibleURL(tab.url)); if (tabs.length == 0){ tabs.push({ @@ -58,12 +88,12 @@ class Workspace { }); } - const promises = tabs.map(tabObject => { + const promises = tabs.map(tab => { return browser.tabs.create({ - url: tabObject.url, - active: tabObject.active, - cookieStoreId: tabObject.cookieStoreId, - windowId: windowId + url: tab.url, + active: tab.active, + cookieStoreId: tab.cookieStoreId, + windowId: this.windowId }); }); @@ -75,14 +105,13 @@ class Workspace { } // Then remove the tabs from the window - async delete(windowId) { + async delete() { await WorkspaceStorage.deleteWorkspaceState(this.id); - await WorkspaceStorage.unregisterWorkspaceToWindow(windowId, this.id); + await WorkspaceStorage.unregisterWorkspaceToWindow(this.windowId, this.id); } async attachTab(tab) { - const tabObject = Object.assign({}, tab); - this.hiddenTabs.push(tabObject); + this.hiddenTabs.push(tab); await this.storeState(); } @@ -96,7 +125,7 @@ class Workspace { await browser.tabs.remove(tab.id); } else { // Otherwise, forget it from hiddenTabs - const index = this.hiddenTabs.findIndex(tabObject => tabObject.id == tab.id); + const index = this.hiddenTabs.findIndex(hiddenTab => hiddenTab.id == tab.id); if (index > -1){ this.hiddenTabs.splice(index, 1); await this.storeState(); @@ -110,13 +139,22 @@ class Workspace { this.name = state.name; this.active = state.active; this.hiddenTabs = state.hiddenTabs; + this.windowId = state.windowId; + + // For backwards compatibility + if (!this.windowId){ + console.log("Backwards compatibility for",this.name); + this.windowId = (await browser.windows.getCurrent()).id; + await this.storeState(); + } } async storeState() { await WorkspaceStorage.storeWorkspaceState(this.id, { name: this.name, active: this.active, - hiddenTabs: this.hiddenTabs + hiddenTabs: this.hiddenTabs, + windowId: this.windowId }); } } diff --git a/background/workspaceStorage.js b/background/workspaceStorage.js index c787c53..6bff6ba 100644 --- a/background/workspaceStorage.js +++ b/background/workspaceStorage.js @@ -1,18 +1,5 @@ const WorkspaceStorage = { - // Deprecated - async fetchWorkspace(workspaceId) { - const key = `workspaces@${workspaceId}`; - const results = await browser.storage.local.get(key); - - if (results[key]){ - const state = results[key]; - return new Workspace(workspaceId, state.name, state.active, state.hiddenTabs); - } else { - return null; - } - }, - async fetchWorkspaceState(workspaceId) { const key = `workspaces@${workspaceId}`; const results = await browser.storage.local.get(key); @@ -51,7 +38,8 @@ const WorkspaceStorage = { const workspaceIds = results[key] || []; const promises = workspaceIds.map(async workspaceId => { - return await WorkspaceStorage.fetchWorkspace(workspaceId); + const state = await WorkspaceStorage.fetchWorkspaceState(workspaceId); + return new Workspace(workspaceId, state); }); return await Promise.all(promises); diff --git a/manifest.json b/manifest.json index 562d84b..b06a44d 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 2, "name": "Tab Workspaces", - "version": "1.1.1", + "version": "1.2.0", "description": "Organize your tabs into workspaces. Switch between workspaces to change which tabs are displayed at the moment.", "icons": { @@ -42,5 +42,9 @@ "background/workspaceStorage.js", "background/messageHandler.js" ] + }, + + "omnibox": { + "keyword": "ws" } } diff --git a/popup/css/popup.css b/popup/css/popup.css index 211f87d..7129b87 100644 --- a/popup/css/popup.css +++ b/popup/css/popup.css @@ -89,9 +89,10 @@ li.workspace-list-entry { width: calc(100% - 30px); cursor: pointer; border-radius: 4px; - overflow: hidden; transition: all .7s cubic-bezier(0.770, 0.000, 0.175, 1.000); font-size: .8em; + position: relative; + z-index: 1; } li.workspace-list-entry.active::after { @@ -104,7 +105,6 @@ li.workspace-list-entry:hover { } li.workspace-edit-entry { - position: relative; background: white; box-shadow: 0 5px 10px rgba(0,0,0,.1); margin: 15px 10px 0; @@ -112,9 +112,10 @@ li.workspace-edit-entry { width: calc(100% - 20px); cursor: pointer; border-radius: 4px; - overflow: hidden; transition: all .7s cubic-bezier(0.770, 0.000, 0.175, 1.000); font-size: .8em; + position: relative; + z-index: 1; } li.workspace-edit-entry input { @@ -151,6 +152,28 @@ li:only-child.workspace-edit-entry a.edit-button-delete { opacity: 0.5; } +li.workspace-list-entry .tabs-qty, +li.workspace-edit-entry .tabs-qty { + position: absolute; + display: inline-block; + text-align: center; + top: 0; + left: 0; + background-color: #FF9A8B; + background-image: linear-gradient(90deg, #FF9A8B 0%, #FF6A88 100%); + border-radius: 10px; + color: white; + font-weight: bold; + font-size: .875em; + min-width: 18px; + padding: 3px 4px; + top: -5px; + right: -5px; + bottom: auto; + left: auto; + z-index: 2; +} + .footer-container { background-color: #0093E9; background-image: linear-gradient(160deg, #0093E9 0%, #80D0C7 100%); diff --git a/popup/js/popup.js b/popup/js/popup.js index 6942d15..376e420 100644 --- a/popup/js/popup.js +++ b/popup/js/popup.js @@ -114,6 +114,12 @@ const Logic = { } li.textContent = workspace.name; li.dataset.workspaceId = workspace.id; + + const span = document.createElement("span"); + span.classList.add("tabs-qty"); + span.textContent = workspace.tabCount; + li.appendChild(span); + fragment.appendChild(li); });