diff --git a/browser/components/tabbrowser/GroupsList.sys.mjs b/browser/components/tabbrowser/GroupsList.sys.mjs new file mode 100644 index 000000000000..944476e7eca4 --- /dev/null +++ b/browser/components/tabbrowser/GroupsList.sys.mjs @@ -0,0 +1,132 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export class GroupsPanel { + constructor({ view, containerNode }) { + this.view = view; + + this.containerNode = containerNode; + this.win = containerNode.ownerGlobal; + this.doc = containerNode.ownerDocument; + this.panelMultiView = null; + this.view.addEventListener("ViewShowing", this); + } + + handleEvent(event) { + switch (event.type) { + case "ViewShowing": + if (event.target == this.view) { + this.panelMultiView = this.view.panelMultiView; + this.#populate(event); + } + break; + case "PanelMultiViewHidden": + if ((this.panelMultiView = event.target)) { + this.#cleanup(); + this.panelMultiView = null; + } + + break; + case "command": + this.#handleCommand(event); + break; + } + } + + #handleCommand(event) { + let groupId = event.target.closest("toolbaritem").groupId; + + switch (event.target.dataset.command) { + case "allTabsGroupView_selectGroup": + let group = this.win.gBrowser.getTabGroupById(groupId); + group.select(); + group.ownerGlobal.focus(); + + break; + case "allTabsGroupView_restoreGroup": + this.win.SessionStore.openSavedTabGroup(groupId, this.win); + break; + } + } + + #setupListeners() { + this.view.addEventListener("command", this); + this.view.panelMultiView.addEventListener("PanelMultiViewHidden", this); + } + + #cleanup() { + this.containerNode.innerHTML = ""; + this.view.removeEventListener("command", this); + } + + #populate() { + let fragment = this.doc.createDocumentFragment(); + let otherWindowGroups = this.win.gBrowser + .getAllTabGroups() + .filter(group => { + return group.ownerGlobal !== this.win; + }); + let savedGroups = this.win.SessionStore.savedGroups; + + if (savedGroups.length + otherWindowGroups.length > 0) { + let header = this.doc.createElement("h2"); + header.setAttribute("class", "subview-subheader"); + this.doc.l10n.setAttributes(header, "tab-group-menu-header"); + fragment.appendChild(header); + } + for (let groupData of otherWindowGroups) { + fragment.appendChild(this.#createRow(groupData)); + } + for (let groupData of savedGroups) { + fragment.appendChild(this.#createRow(groupData, "closed")); + } + this.containerNode.appendChild(fragment); + this.#setupListeners(); + } + + #createRow(group, kind = "open") { + let { doc } = this; + let row = doc.createXULElement("toolbaritem"); + row.setAttribute("class", "all-tabs-item all-tabs-group-item"); + row.setAttribute("context", "tabContextMenu"); + row.style.setProperty( + "--tab-group-color", + `var(--tab-group-color-${group.color})` + ); + row.style.setProperty( + "--tab-group-color-invert", + `var(--tab-group-color-${group.color}-invert)` + ); + row.style.setProperty( + "--tab-group-color-pale", + `var(--tab-group-color-${group.color}-pale)` + ); + row.groupId = group.id; + let button = doc.createXULElement("toolbarbutton"); + button.setAttribute( + "class", + "all-tabs-button subviewbutton subviewbutton-iconic all-tabs-group-action-button" + ); + if (kind != "open") { + button.classList.add("all-tabs-group-saved-group"); + button.dataset.command = "allTabsGroupView_restoreGroup"; + } else { + button.dataset.command = "allTabsGroupView_selectGroup"; + } + button.setAttribute("flex", "1"); + button.setAttribute("crop", "end"); + + if (group.name) { + button.setAttribute("label", group.name); + } else { + doc.l10n + .formatValues([{ id: "tab-group-name-default" }]) + .then(([msg]) => { + button.setAttribute("label", msg); + }); + } + row.appendChild(button); + return row; + } +} diff --git a/browser/components/tabbrowser/TabsList.sys.mjs b/browser/components/tabbrowser/TabsList.sys.mjs index 44878afb8ff5..7c08d02f2b9a 100644 --- a/browser/components/tabbrowser/TabsList.sys.mjs +++ b/browser/components/tabbrowser/TabsList.sys.mjs @@ -102,9 +102,14 @@ class TabsListBase { */ _populate() { let fragment = this.doc.createDocumentFragment(); + let currentGroupId; for (let tab of this.gBrowser.tabs) { if (this.filterFn(tab)) { + if (tab.group && tab.group.id != currentGroupId) { + fragment.appendChild(this._createGroupRow(tab.group)); + currentGroupId = tab.group.id; + } fragment.appendChild(this._createRow(tab)); } } @@ -121,6 +126,10 @@ class TabsListBase { * Remove the menuitems from the DOM, cleanup internal state and listeners. */ _cleanup() { + this.doc + .querySelectorAll(".all-tabs-group-button") + .forEach(node => node.remove()); + for (let item of this.rows) { item.remove(); } @@ -262,6 +271,9 @@ export class TabsPanel extends TabsListBase { this.gBrowser.removeTab(event.target.tab); break; } + if (event.target.classList.contains("all-tabs-group-button")) { + this.gBrowser.getTabGroupById(event.target.groupId).select(); + } // fall through default: super.handleEvent(event); @@ -275,7 +287,10 @@ export class TabsPanel extends TabsListBase { // The loading throbber can't be set until the toolbarbutton is rendered, // so set the image attributes again now that the elements are in the DOM. for (let row of this.rows) { - this._setImageAttributes(row, row.tab); + // Ensure this isn't a group label + if (row.tab) { + this._setImageAttributes(row, row.tab); + } } } @@ -324,6 +339,10 @@ export class TabsPanel extends TabsListBase { }); } + if (tab.group) { + row.classList.add("grouped"); + } + row.appendChild(button); let muteButton = doc.createXULElement("toolbarbutton"); @@ -352,6 +371,49 @@ export class TabsPanel extends TabsListBase { return row; } + _createGroupRow(group) { + let { doc } = this; + let row = doc.createXULElement("toolbaritem"); + row.setAttribute("class", "all-tabs-item all-tabs-group-item"); + row.setAttribute("context", "none"); + row.style.setProperty( + "--tab-group-color", + `var(--tab-group-color-${group.color})` + ); + row.style.setProperty( + "--tab-group-color-invert", + `var(--tab-group-color-${group.color}-invert)` + ); + row.style.setProperty( + "--tab-group-color-pale", + `var(--tab-group-color-${group.color}-pale)` + ); + row.addEventListener("command", this); + let button = doc.createXULElement("toolbarbutton"); + button.setAttribute( + "class", + "all-tabs-button all-tabs-group-button subviewbutton subviewbutton-iconic" + ); + button.setAttribute("flex", "1"); + button.setAttribute("crop", "end"); + button.group = group; + button.groupId = group.id; + + if (group.label) { + button.label = group.label; + } else { + doc.l10n + .formatValues([{ id: "tab-group-name-default" }]) + .then(([msg]) => { + button.label = msg; + }); + } + + button.image = "chrome://browser/skin/tabbrowser/tab-group-chicklet.svg"; + row.appendChild(button); + return row; + } + _setRowAttributes(row, tab) { setAttributes(row, { selected: tab.selected }); diff --git a/browser/components/tabbrowser/content/browser-allTabsMenu.inc.xhtml b/browser/components/tabbrowser/content/browser-allTabsMenu.inc.xhtml index 61d48237a63b..6349ed3a784f 100644 --- a/browser/components/tabbrowser/content/browser-allTabsMenu.inc.xhtml +++ b/browser/components/tabbrowser/content/browser-allTabsMenu.inc.xhtml @@ -23,6 +23,9 @@ class="subviewbutton subviewbutton-nav" closemenu="none" data-l10n-id="all-tabs-menu-hidden-tabs"/> + + + diff --git a/browser/components/tabbrowser/content/browser-allTabsMenu.js b/browser/components/tabbrowser/content/browser-allTabsMenu.js index 8507334e6272..fa23106678c8 100644 --- a/browser/components/tabbrowser/content/browser-allTabsMenu.js +++ b/browser/components/tabbrowser/content/browser-allTabsMenu.js @@ -7,6 +7,8 @@ ChromeUtils.defineESModuleGetters(this, { BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.sys.mjs", + GroupsPanel: "resource:///modules/GroupsList.sys.mjs", + NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs", TabsPanel: "resource:///modules/TabsList.sys.mjs", }); @@ -19,6 +21,7 @@ var gTabsPanel = { containerTabsView: "allTabsMenu-containerTabsView", hiddenTabsButton: "allTabsMenu-hiddenTabsButton", hiddenTabsView: "allTabsMenu-hiddenTabsView", + groupsView: "allTabsMenu-groupsView", }, _initialized: false, _initializedElements: false, @@ -60,6 +63,11 @@ var gTabsPanel = { containerNode: this.allTabsViewTabs, filterFn: tab => !tab.hidden, dropIndicator: this.dropIndicator, + showGroups: true, + }); + this.groupsPanel = new GroupsPanel({ + view: this.allTabsView, + containerNode: this.groupsView, }); this.allTabsView.addEventListener("ViewShowing", () => { diff --git a/browser/components/tabbrowser/content/tabgroup.js b/browser/components/tabbrowser/content/tabgroup.js index afbca4eec6be..c3d3ae6d4b4b 100644 --- a/browser/components/tabbrowser/content/tabgroup.js +++ b/browser/components/tabbrowser/content/tabgroup.js @@ -138,6 +138,15 @@ this.#updateLabelAriaAttributes(); } + // alias for label + get name() { + return this.label; + } + + set name(newName) { + this.label = newName; + } + get collapsed() { return this.hasAttribute("collapsed"); } @@ -224,6 +233,19 @@ on_TabSelect() { this.collapsed = false; } + + /** + * If one of this group's tabs is the selected tab, this will do nothing. + * Otherwise, it will expand the group if collapsed, and select the first + * tab in its list. + */ + select() { + this.collapsed = false; + if (gBrowser.selectedTab.group == this) { + return; + } + gBrowser.selectedTab = this.tabs[0]; + } } customElements.define("tab-group", MozTabbrowserTabGroup); diff --git a/browser/components/tabbrowser/moz.build b/browser/components/tabbrowser/moz.build index 2f85bb36ef35..d9325d918551 100644 --- a/browser/components/tabbrowser/moz.build +++ b/browser/components/tabbrowser/moz.build @@ -9,6 +9,7 @@ JAR_MANIFESTS += ["jar.mn"] EXTRA_JS_MODULES += [ "AsyncTabSwitcher.sys.mjs", + "GroupsList.sys.mjs", "NewTabPagePreloading.sys.mjs", "OpenInTabsUtils.sys.mjs", "TabsList.sys.mjs", diff --git a/browser/components/tabbrowser/test/browser/tabs/browser.toml b/browser/components/tabbrowser/test/browser/tabs/browser.toml index 22b41488df6d..e2cc6f4a12cc 100644 --- a/browser/components/tabbrowser/test/browser/tabs/browser.toml +++ b/browser/components/tabbrowser/test/browser/tabs/browser.toml @@ -495,6 +495,9 @@ tags = "vertical-tabs" ["browser_tab_manager_drag.js"] tags = "vertical-tabs" +["browser_tab_manager_groups.js"] +tags = "vertical-tabs" + ["browser_tab_manager_keyboard_access.js"] tags = "vertical-tabs" diff --git a/browser/components/tabbrowser/test/browser/tabs/browser_tab_groups.js b/browser/components/tabbrowser/test/browser/tabs/browser_tab_groups.js index aae5d2d343c3..7e8c36fb37ad 100644 --- a/browser/components/tabbrowser/test/browser/tabs/browser_tab_groups.js +++ b/browser/components/tabbrowser/test/browser/tabs/browser_tab_groups.js @@ -590,6 +590,33 @@ add_task(async function test_moveTabBetweenGroups() { await removeTabGroup(group2); }); +add_task(async function test_tabGroupSelect() { + let tab1 = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let tab3 = BrowserTestUtils.addTab(gBrowser, "about:blank"); + let tab1Added = BrowserTestUtils.waitForEvent(tab1, "TabGrouped"); + let tab2Added = BrowserTestUtils.waitForEvent(tab2, "TabGrouped"); + let group = gBrowser.addTabGroup([tab1, tab2]); + await Promise.allSettled([tab1Added, tab2Added]); + gBrowser.selectTabAtIndex(tab3._tPos); + Assert.ok(tab3.selected, "Tab 3 is selected"); + group.select(); + Assert.ok(group.tabs[0].selected, "First tab is selected"); + gBrowser.selectTabAtIndex(group.tabs[1]._tPos); + Assert.ok(group.tabs[1].selected, "Second tab is selected"); + group.select(); + Assert.ok(group.tabs[1].selected, "Second tab is still selected"); + group.collapsed = true; + Assert.ok(group.collapsed, "Group is collapsed"); + Assert.ok(tab3.selected, "Tab 3 is selected"); + group.select(); + Assert.ok(!group.collapsed, "Group is no longer collapsed"); + Assert.ok(group.tabs[0].selected, "First tab in group is selected"); + + await removeTabGroup(group); + BrowserTestUtils.removeTab(tab3); +}); + // Context menu tests // --- diff --git a/browser/components/tabbrowser/test/browser/tabs/browser_tab_manager_groups.js b/browser/components/tabbrowser/test/browser/tabs/browser_tab_manager_groups.js new file mode 100644 index 000000000000..b4ccf8efb540 --- /dev/null +++ b/browser/components/tabbrowser/test/browser/tabs/browser_tab_manager_groups.js @@ -0,0 +1,181 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.groups.enabled", true]], + }); + + const tabGroups = SessionStore.getSavedTabGroups(); + tabGroups.forEach(tabGroup => SessionStore.forgetSavedTabGroup(tabGroup.id)); + + window.gTabsPanel.init(); +}); + +async function openTabsMenu(win = window) { + return new Promise(resolve => { + BrowserTestUtils.waitForEvent( + win.document.getElementById("allTabsMenu-allTabsView"), + "ViewShown" + ).then(event => resolve(event.target)); + win.document.getElementById("alltabs-button").click(); + }); +} + +async function closeTabsMenu(win = window) { + return new Promise(resolve => { + let panel = win.document + .getElementById("allTabsMenu-allTabsView") + .closest("panel"); + BrowserTestUtils.waitForPopupEvent(panel, "hidden").then(event => + resolve(event.target) + ); + panel.hidePopup(); + }); +} + +/** + * Tests that grouped tabs in alltabsmenu are prepended by + * a group indicator + */ +add_task(async function test_allTabsView() { + let tabs = []; + for (let i = 1; i <= 5; i++) { + tabs.push( + await addTab(`data:text/plain,tab${i}`, { + skipAnimation: true, + }) + ); + } + gBrowser.addTabGroup([tabs[0], tabs[1]], { + label: "Test Group", + }); + gBrowser.addTabGroup([tabs[2], tabs[3]]); + + let allTabsMenu = await openTabsMenu(); + + let tabButtons = allTabsMenu.querySelectorAll( + "#allTabsMenu-allTabsView-tabs .all-tabs-button" + ); + let expectedLabels = [ + "New Tab", + "data:text/plain,tab5", + "Test Group", + "data:text/plain,tab1", + "data:text/plain,tab2", + "Unnamed Group", + "data:text/plain,tab3", + "data:text/plain,tab4", + ]; + tabButtons.forEach((button, i) => { + Assert.equal( + button.label, + expectedLabels[i], + `Expected: ${expectedLabels[i]}` + ); + }); + + await closeTabsMenu(); + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } +}); + +/** + * Tests that groups appear in the supplementary group menu + * when they are saved (and closed,) or open in another window. + * Clicking an open group in this menu focuses it, + * and clicking on a saved group restores it. + */ +add_task(async function test_tabGroupsView() { + let tabs = []; + for (let i = 1; i <= 5; i++) { + tabs.push( + await addTab(`data:text/plain,tab${i}`, { + skipAnimation: true, + }) + ); + } + let group1 = gBrowser.addTabGroup([tabs[0], tabs[1]], { + id: "test-saved-group", + label: "Test Saved Group", + }); + let group2 = gBrowser.addTabGroup([tabs[2], tabs[3]], { + label: "Test Open Group", + }); + + let newWindow = await BrowserTestUtils.openNewBrowserWindow(); + newWindow.gTabsPanel.init(); + + let allTabsMenu = await openTabsMenu(newWindow); + Assert.equal( + allTabsMenu.querySelectorAll("#allTabsMenu-groupsView .all-tabs-button") + .length, + 2, + "Both groups shown in groups list" + ); + Assert.ok( + !allTabsMenu.querySelector( + "#allTabsMenu-groupsView .all-tabs-button.all-tabs-group-saved-group" + ), + "Neither group is shown as saved" + ); + + await closeTabsMenu(newWindow); + + group1.save(); + await removeTabGroup(group1); + + Assert.ok(!gBrowser.getTabGroupById("test-saved-group"), "Group 1 removed"); + + allTabsMenu = await openTabsMenu(newWindow); + Assert.equal( + allTabsMenu.querySelectorAll("#allTabsMenu-groupsView .all-tabs-button") + .length, + 2, + "Both groups shown in groups list" + ); + let savedGroupButton = allTabsMenu.querySelector( + "#allTabsMenu-groupsView .all-tabs-button.all-tabs-group-saved-group" + ); + Assert.equal( + savedGroupButton.label, + "Test Saved Group", + "Saved group appears as saved" + ); + + // Clicking on an open group should select that group in the origin window + let openGroupButton = allTabsMenu.querySelector( + "#allTabsMenu-groupsView .all-tabs-button:not(.all-tabs-group-saved-group)" + ); + openGroupButton.click(); + Assert.equal( + gBrowser.selectedTab.group.id, + group2.id, + "Tab in group 2 is selected" + ); + + await BrowserTestUtils.closeWindow(newWindow, { animate: false }); + + // Clicking on a saved group should restore the group to the current window + allTabsMenu = await openTabsMenu(); + savedGroupButton = allTabsMenu.querySelector( + "#allTabsMenu-groupsView .all-tabs-button.all-tabs-group-saved-group" + ); + savedGroupButton.click(); + group1 = gBrowser.getTabGroupById("test-saved-group"); + Assert.ok(group1, "Group 1 has been restored"); + allTabsMenu = await openTabsMenu(); + Assert.ok( + !allTabsMenu.querySelector("#allTabsMenu-groupsView .all-tabs-button"), + "Groups list is now empty for this window" + ); + + await closeTabsMenu(); + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + await removeTabGroup(group1); +}); diff --git a/browser/locales/en-US/browser/tabbrowser.ftl b/browser/locales/en-US/browser/tabbrowser.ftl index d79d64ca96a0..274e731389df 100644 --- a/browser/locales/en-US/browser/tabbrowser.ftl +++ b/browser/locales/en-US/browser/tabbrowser.ftl @@ -191,6 +191,7 @@ tabbrowser-manager-close-tab = ## Tab Groups +tab-group-name-default = Unnamed Group tab-group-editor-title-create = Create tab group tab-group-editor-title-edit = Manage tab group tab-group-editor-name-label = Name @@ -200,6 +201,8 @@ tab-group-editor-cancel = .label = Cancel .accesskey = C +tab-group-menu-header = Tab groups + tab-context-unnamed-group = .label = Unnamed group diff --git a/browser/themes/shared/tabbrowser/tabs.css b/browser/themes/shared/tabbrowser/tabs.css index f9112aabd76f..949ad0ecbea1 100644 --- a/browser/themes/shared/tabbrowser/tabs.css +++ b/browser/themes/shared/tabbrowser/tabs.css @@ -1465,8 +1465,22 @@ toolbar:not(#TabsToolbar) #firefox-view-button { } } +#allTabsMenu-groupsView { + flex: auto; + flex-grow: 0; + + &:empty, + &:empty + toolbarseparator { + display: none; + } +} + .all-tabs-item { margin-inline: var(--arrowpanel-menuitem-margin-inline); + + &.grouped { + margin-inline-start: calc(var(--arrowpanel-menuitem-margin-inline) * 2); + } border-radius: var(--arrowpanel-menuitem-border-radius); &[selected] { @@ -1497,6 +1511,19 @@ toolbar:not(#TabsToolbar) #firefox-view-button { } } +.all-tabs-group-item { + > .subviewbutton-iconic > .toolbarbutton-icon { + list-style-image: url("chrome://browser/skin/tabbrowser/tab-group-chicklet.svg"); + fill: light-dark(var(--tab-group-color), var(--tab-group-color-invert)); + } + + > .all-tabs-group-saved-group > .toolbarbutton-icon { + -moz-context-properties: fill, stroke; + fill: transparent; + stroke: light-dark(var(--tab-group-color), var(--tab-group-color-invert)); + } +} + .all-tabs-button { list-style-image: url("chrome://global/skin/icons/defaultFavicon.svg"); }