From 6477a1f8ba2b4ad0cb8ebc0396ad4a1fb36ce798 Mon Sep 17 00:00:00 2001 From: Vivek Agrawal <1678871+vmnit@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:41:54 +0000 Subject: [PATCH] Bug 2076116 - Add tab group actions to the Firefox View open tabs menu The open tabs menu can now add a tab to a new group, to an existing open or closed group, and remove it from its group, matching the tab strip. The group policy, actions and chicklet colors are extracted into TabGroupMenu.sys.mjs and tab-group-colors.css so the tab strip, the all tabs panel and Firefox View share one implementation. Co-authored-by: Cursor --- .../components/firefoxview/firefoxview.css | 45 +++ browser/components/firefoxview/opentabs.mjs | 153 ++++++++++ .../browser/browser_opentabs_firefoxview.js | 274 +++++++++++++++++- .../components/tabbrowser/GroupsList.sys.mjs | 23 +- .../tabbrowser/TabGroupMenu.sys.mjs | 248 ++++++++++++++++ .../tabbrowser/content/tab-context-menu.js | 120 ++------ .../tabbrowser/content/tabgroup-menu.js | 15 +- .../tabbrowser/content/tabgroup.mjs | 4 +- browser/components/tabbrowser/moz.build | 3 + .../locales/en-US/browser/fxviewTabList.ftl | 14 + browser/themes/shared/jar.inc.mn | 1 + .../shared/tabbrowser/tab-group-colors.css | 44 +++ browser/themes/shared/tabbrowser/tabs.css | 33 +-- 13 files changed, 816 insertions(+), 161 deletions(-) create mode 100644 browser/components/tabbrowser/TabGroupMenu.sys.mjs create mode 100644 browser/themes/shared/tabbrowser/tab-group-colors.css diff --git a/browser/components/firefoxview/firefoxview.css b/browser/components/firefoxview/firefoxview.css index 35b3a1e4f4ff7..ce6c4e31fa126 100644 --- a/browser/components/firefoxview/firefoxview.css +++ b/browser/components/firefoxview/firefoxview.css @@ -3,6 +3,11 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ @import url("chrome://global/skin/in-content/common.css"); +/* Tab group chicklet colors for the Open tabs context menu. about:firefoxview + doesn't load tabs.css, so it pulls in the same two sources the tab strip + uses: the generated Nova tokens, and the unlayered pre-Nova overrides. */ +@import url("chrome://browser/skin/tabbrowser/tab.tokens.css"); +@import url("chrome://browser/skin/tabbrowser/tab-group-colors.css"); :root { /* override --background-color-canvas from tokens-shared.css */ @@ -145,6 +150,46 @@ panel-item::part(button):hover:active { background-color: var(--fxview-element-background-active); } +/* panel-item copies child text into its shadow label, so the chicklet is the + button's background image rather than a child element. Color variables are + set inline on the item, same as the tab-strip menu. */ +panel-item.tab-group-icon::part(button) { + --panel-item-icon: url("chrome://browser/skin/tabbrowser/tab-group-chicklet.svg"); + background-image: url("chrome://browser/skin/tabbrowser/tab-group-chicklet.svg"); + background-repeat: no-repeat; + background-size: var(--icon-size); + background-position: 12px center; + -moz-context-properties: fill, stroke; + /* stylelint-disable-next-line stylelint-plugin-mozilla/use-design-tokens */ + fill: light-dark(var(--tab-group-color), var(--tab-group-color-invert)); + /* stylelint-disable-next-line stylelint-plugin-mozilla/use-design-tokens */ + stroke: light-dark(var(--tab-group-color), var(--tab-group-color-invert)); + padding-inline-start: calc(12px + var(--icon-size) + var(--space-small)); + + &:dir(rtl), + &:-moz-locale-dir(rtl) { + background-position-x: right 12px; + } + + @media -moz-pref("browser.nova.enabled") { + /* stylelint-disable-next-line stylelint-plugin-mozilla/use-design-tokens */ + fill: var(--tab-group-background-color); + /* stylelint-disable-next-line stylelint-plugin-mozilla/use-design-tokens */ + stroke: var(--tab-group-background-color); + } +} + +panel-item.tab-group-icon-closed::part(button) { + fill: transparent; + /* stylelint-disable-next-line stylelint-plugin-mozilla/use-design-tokens */ + stroke: light-dark(var(--tab-group-color), var(--tab-group-color-invert)); + + @media -moz-pref("browser.nova.enabled") { + /* stylelint-disable-next-line stylelint-plugin-mozilla/use-design-tokens */ + stroke: var(--tab-group-background-color); + } +} + fxview-empty-state { &:not([isSelectedTab]) button[slot="primary-action"] { margin-inline-start: 0; diff --git a/browser/components/firefoxview/opentabs.mjs b/browser/components/firefoxview/opentabs.mjs index bbd99161d089f..8700603c4121b 100644 --- a/browser/components/firefoxview/opentabs.mjs +++ b/browser/components/firefoxview/opentabs.mjs @@ -6,6 +6,8 @@ import { classMap, html, map, + nothing, + styleMap, when, } from "chrome://global/content/vendor/lit.all.mjs"; import { MozLitElement } from "chrome://global/content/lit-utils.mjs"; @@ -29,6 +31,7 @@ ChromeUtils.defineESModuleGetters(lazy, { PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", SpecialMessageActions: "resource://messaging-system/lib/SpecialMessageActions.sys.mjs", + TabGroupMenu: "moz-src:///browser/components/tabbrowser/TabGroupMenu.sys.mjs", TabMetrics: "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs", }); @@ -48,6 +51,13 @@ XPCOMUtils.defineLazyPreferenceGetter( "identity.fxaccounts.enabled" ); +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "tabGroupsEnabled", + "browser.tabs.groups.enabled", + false +); + const TOPIC_DEVICESTATE_CHANGED = "firefox-view.devicestate.changed"; const TOPIC_DEVICELIST_UPDATED = "fxaccounts:devicelist_updated"; @@ -793,6 +803,148 @@ class OpenTabsContextMenu extends MozLitElement { this.ownerViewPage.recordContextMenuTelemetry("move-tab-window", e); } + tabMetricsContext() { + // No Firefox View source exists on TabMetrics. TAB_MENU matches the tab + // strip's group actions. + return lazy.TabMetrics.userTriggeredContext( + lazy.TabMetrics.METRIC_SOURCE.TAB_MENU + ); + } + + moveTabToNewGroup(e) { + const tab = this.triggerNode?.tabElement; + if (!tab?.documentGlobal?.gBrowser) { + return; + } + // The group editor opens in the tab's window. Focus that window first so + // the panel isn't buried when the tab lives in another window. Unlike the + // tab strip, Firefox View doesn't select the tab, so this view stays put. + tab.documentGlobal.focus(); + lazy.TabGroupMenu.createGroupFromTabs([tab], { + metricsContext: this.tabMetricsContext(), + }); + this.ownerViewPage.recordContextMenuTelemetry("move-tab-new-group", e); + } + + moveTabToGroup(e) { + const tab = this.triggerNode?.tabElement; + const groupId = e.currentTarget.getAttribute("tab-group-id"); + const group = tab?.documentGlobal?.gBrowser?.getTabGroupById(groupId); + if (!group) { + return; + } + lazy.TabGroupMenu.addTabsToGroup([tab], group, this.tabMetricsContext()); + this.ownerViewPage.recordContextMenuTelemetry("move-tab-group", e); + } + + moveTabToSavedGroup(e) { + const tab = this.triggerNode?.tabElement; + const groupId = e.currentTarget.getAttribute("tab-group-id"); + if (!tab?.documentGlobal?.gBrowser || !groupId) { + return; + } + lazy.TabGroupMenu.addTabsToSavedGroup( + [tab], + groupId, + this.tabMetricsContext() + ); + this.ownerViewPage.recordContextMenuTelemetry("move-tab-saved-group", e); + } + + ungroupTab(e) { + const tab = this.triggerNode?.tabElement; + if (!tab?.group) { + return; + } + lazy.TabGroupMenu.ungroupTabs([tab]); + this.ownerViewPage.recordContextMenuTelemetry("ungroup-tab", e); + } + + groupMenuItemTemplate(group, isSaved) { + const label = group.label ?? group.name; + return html`${label ?? ""}`; + } + + /** + * Saved groups sit in this submenu under a heading. A nested panel-list + * submenu resolves its owning panel to the outermost list, so Closed Groups + * is not a further submenu. + * + * @param {MozTabbrowserTabGroup[]} openGroups + * @param {object[]} savedGroups + */ + tabGroupSubmenuTemplate(openGroups, savedGroups) { + return html` + + ${when( + openGroups.length, + () => html` +
+ ${map(openGroups, group => this.groupMenuItemTemplate(group, false))} + ` + )} + ${when( + savedGroups.length, + () => html` +
+ + ${map(savedGroups, group => this.groupMenuItemTemplate(group, true))} + ` + )} +
`; + } + + tabGroupMenuTemplate() { + const tab = this.triggerNode?.tabElement; + if (!tab || !lazy.tabGroupsEnabled) { + return null; + } + const { openGroups, savedGroups } = lazy.TabGroupMenu.getGroupsToMoveTo([ + tab, + ]); + if (!openGroups.length && !savedGroups.length) { + return html``; + } + return html`${this.tabGroupSubmenuTemplate(openGroups, savedGroups)}`; + } + + ungroupTabTemplate() { + const tab = this.triggerNode?.tabElement; + if (!tab?.group || !lazy.tabGroupsEnabled) { + return null; + } + return html``; + } + moveMenuTemplate() { const tab = this.triggerNode?.tabElement; if (!tab) { @@ -1047,6 +1199,7 @@ class OpenTabsContextMenu extends MozLitElement { submenu="move-tab-menu" >${this.moveMenuTemplate()} + ${this.tabGroupMenuTemplate()} ${this.ungroupTabTemplate()} item.url == url); + Assert.ok(row, `Found an open tabs row for ${url}`); + return row; +} + +async function cleanupOpenTabsGroups() { + for (let group of [...gBrowser.tabGroups]) { + await gBrowser.removeTabGroup(group); + } + // Leave a blank tab rather than the last tab the task opened. Two tabs on + // the same URL are indistinguishable to rowForURL, so the next task would + // otherwise be looking up a row that may belong to either of them. + let blankTab = BrowserTestUtils.addTab(gBrowser, "about:blank"); + for (let tab of [...gBrowser.tabs]) { + if (tab != blankTab) { + BrowserTestUtils.removeTab(tab); + } + } +} + +add_task(async function test_move_tab_to_new_group() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.groups.enabled", true]], + }); + try { + await withFirefoxView({}, async () => { + let [cards, rows] = await moreMenuSetup([TEST_URL2, TEST_URL3]); + let row = rowForURL(rows, TEST_URL2); + let tab = row.tabElement; + Assert.equal(tab.group, null, "Tab starts ungrouped"); + + let panelList = await openContextMenuForItem(row, cards[0]); + let newGroupItem = panelList.querySelector( + "panel-item[data-l10n-id=fxviewtabrow-move-tab-new-group]" + ); + ok(newGroupItem, "Flat Add Tab to New Group item is shown"); + ok( + !panelList.querySelector( + "panel-item[data-l10n-id=fxviewtabrow-move-tab-group]" + ), + "Add Tab to Group submenu is hidden when there are no groups" + ); + + let editor = document.getElementById("tab-group-editor"); + let panelShown = BrowserTestUtils.waitForPopupEvent( + editor.panel, + "shown" + ); + let created = BrowserTestUtils.waitForEvent(editor, "TabGroupCreateDone"); + Services.fog.testResetFOG(); + clickPanelItem(newGroupItem); + let contextEvents = Glean.firefoxviewNext.contextMenuTabs.testGetValue(); + Assert.equal(contextEvents.length, 1, "Expected one context menu event"); + Assert.deepEqual( + { menu_action: "move-tab-new-group", data_type: "opentabs" }, + contextEvents[0].extra + ); + await panelShown; + Assert.ok(tab.group, "Tab was added to a new group"); + Assert.ok(tab.group.tabs.includes(tab), "New group contains the tab"); + // Confirming the editor keeps the group. Hiding the popup is enough; + // the name field may not be focused after the menu closes. + editor.panel.hidePopup(); + await created; + Assert.ok(tab.group, "Confirming the editor kept the new group"); + }); + } finally { + await SpecialPowers.popPrefEnv(); + await cleanupOpenTabsGroups(); + } +}); + +add_task(async function test_move_tab_to_existing_group() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.groups.enabled", true]], + }); + try { + await withFirefoxView({}, async () => { + let [cards, rows] = await moreMenuSetup([TEST_URL2, TEST_URL3]); + let groupedRow = rowForURL(rows, TEST_URL3); + let group = gBrowser.addTabGroup([groupedRow.tabElement], { + label: "Work", + }); + + let openTabs = cards[0].ownerDocument.querySelector( + "view-opentabs[name=opentabs]" + ); + await waitUntilRowsMatch(openTabs, 0, getVisibleTabURLs()); + rows = await getTabRowsForCard(cards[0]); + + let ownGroupRow = rowForURL(rows, TEST_URL3); + let ownGroupMenu = await openContextMenuForItem(ownGroupRow, cards[0]); + ok( + ownGroupMenu.querySelector( + "panel-item[data-l10n-id=fxviewtabrow-move-tab-new-group]" + ), + "A tab already in the only group gets Add Tab to New Group" + ); + ok( + !ownGroupMenu.querySelector(`[tab-group-id="${group.id}"]`), + "The tab's own group is not listed" + ); + ownGroupMenu.hide(undefined, { force: true }); + + let targetRow = rowForURL(rows, TEST_URL2); + let targetTab = targetRow.tabElement; + let panelList = await openContextMenuForItem(targetRow, cards[0]); + let groupMenuItem = panelList.querySelector( + "panel-item[data-l10n-id=fxviewtabrow-move-tab-group]" + ); + ok(groupMenuItem, "Add Tab to Group submenu is shown"); + ok( + !panelList.querySelector( + "panel-item[data-l10n-id=fxviewtabrow-move-tab-new-group]" + ), + "Flat Add Tab to New Group item is hidden when a group exists" + ); + + let submenu = groupMenuItem.shadowRoot.querySelector( + "panel-list[id=move-tab-group-menu]" + ); + ok(submenu, "Add Tab to Group submenu exists"); + ok( + submenu.querySelector( + "panel-item[data-l10n-id=fxviewtabrow-move-tab-group-new]" + ), + "Submenu lists New Group" + ); + let groupItem = submenu.querySelector( + `panel-item[tab-group-id="${group.id}"]` + ); + ok(groupItem, "Submenu lists the other open group"); + Assert.equal(groupItem.textContent, "Work", "Group item uses its label"); + Assert.equal( + groupItem.style.getPropertyValue("--tab-group-color"), + `var(--tab-group-${group.color})`, + "Group item carries the chicklet color" + ); + + Services.fog.testResetFOG(); + clickPanelItem(groupItem); + let contextEvents = Glean.firefoxviewNext.contextMenuTabs.testGetValue(); + Assert.equal(contextEvents.length, 1, "Expected one context menu event"); + Assert.deepEqual( + { menu_action: "move-tab-group", data_type: "opentabs" }, + contextEvents[0].extra + ); + Assert.equal(targetTab.group, group, "Tab moved into the chosen group"); + Assert.ok(group.tabs.includes(targetTab), "Group contains the moved tab"); + }); + } finally { + await SpecialPowers.popPrefEnv(); + await cleanupOpenTabsGroups(); + } +}); + +add_task(async function test_ungroup_tab() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.groups.enabled", true]], + }); + try { + await withFirefoxView({}, async () => { + let [cards, rows] = await moreMenuSetup([TEST_URL2, TEST_URL3]); + let ungroupedRow = rowForURL(rows, TEST_URL2); + let groupedTab = rowForURL(rows, TEST_URL3).tabElement; + let group = gBrowser.addTabGroup([groupedTab], { label: "Work" }); + + let openTabs = cards[0].ownerDocument.querySelector( + "view-opentabs[name=opentabs]" + ); + await waitUntilRowsMatch(openTabs, 0, getVisibleTabURLs()); + rows = await getTabRowsForCard(cards[0]); + + let ungroupedMenu = await openContextMenuForItem( + rowForURL(rows, ungroupedRow.url), + cards[0] + ); + ok( + !ungroupedMenu.querySelector( + "panel-item[data-l10n-id=fxviewtabrow-ungroup-tab]" + ), + "Remove from Group is absent for a tab that is not in a group" + ); + ungroupedMenu.hide(undefined, { force: true }); + + let panelList = await openContextMenuForItem( + rowForURL(rows, TEST_URL3), + cards[0] + ); + let ungroupItem = panelList.querySelector( + "panel-item[data-l10n-id=fxviewtabrow-ungroup-tab]" + ); + ok(ungroupItem, "Remove from Group is shown for a tab in a group"); + + // The group holds only this tab, so emptying it removes the group. That + // happens off the click, hence the event rather than a bare assertion. + let groupRemoved = BrowserTestUtils.waitForEvent( + group, + "TabGroupRemoved" + ); + Services.fog.testResetFOG(); + clickPanelItem(ungroupItem); + let contextEvents = Glean.firefoxviewNext.contextMenuTabs.testGetValue(); + Assert.equal(contextEvents.length, 1, "Expected one context menu event"); + Assert.deepEqual( + { menu_action: "ungroup-tab", data_type: "opentabs" }, + contextEvents[0].extra + ); + Assert.equal(groupedTab.group, null, "Tab was removed from its group"); + await groupRemoved; + Assert.ok( + !gBrowser.tabGroups.includes(group), + "Emptying the group removed it" + ); + }); + } finally { + await SpecialPowers.popPrefEnv(); + await cleanupOpenTabsGroups(); + } +}); + +add_task(async function test_tab_group_menu_hidden_when_groups_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.groups.enabled", false]], + }); + try { + await withFirefoxView({}, async () => { + let [cards, rows] = await moreMenuSetup([TEST_URL2]); + let row = rowForURL(rows, TEST_URL2); + let panelList = await openContextMenuForItem(row, cards[0]); + ok( + !panelList.querySelector( + "panel-item[data-l10n-id=fxviewtabrow-move-tab-new-group]" + ), + "Add Tab to New Group is absent when groups are disabled" + ); + ok( + !panelList.querySelector( + "panel-item[data-l10n-id=fxviewtabrow-move-tab-group]" + ), + "Add Tab to Group is absent when groups are disabled" + ); + ok( + !panelList.querySelector( + "panel-item[data-l10n-id=fxviewtabrow-ungroup-tab]" + ), + "Remove from Group is absent when groups are disabled" + ); + panelList.hide(undefined, { force: true }); + }); + } finally { + await SpecialPowers.popPrefEnv(); + await cleanupOpenTabsGroups(); + } +}); diff --git a/browser/components/tabbrowser/GroupsList.sys.mjs b/browser/components/tabbrowser/GroupsList.sys.mjs index 5a55d0f61ff5c..3cc525ad2266c 100644 --- a/browser/components/tabbrowser/GroupsList.sys.mjs +++ b/browser/components/tabbrowser/GroupsList.sys.mjs @@ -3,6 +3,7 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { PrivateBrowsingUtils } from "resource://gre/modules/PrivateBrowsingUtils.sys.mjs"; +import { TabGroupMenu } from "moz-src:///browser/components/tabbrowser/TabGroupMenu.sys.mjs"; import { TabMetrics } from "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; @@ -181,22 +182,12 @@ export class GroupsPanel { let row = doc.createXULElement("toolbaritem"); row.setAttribute("class", "all-tabs-item all-tabs-group-item"); - row.style.setProperty( - "--tab-group-color", - `var(--tab-group-${group.color})` - ); - row.style.setProperty( - "--tab-group-color-invert", - `var(--tab-group-${group.color}-invert)` - ); - row.style.setProperty( - "--tab-group-color-pale", - `var(--tab-group-${group.color}-pale)` - ); - row.style.setProperty( - "--tab-group-background-color", - `var(--tab-group-${group.color})` - ); + for (let [property, value] of Object.entries( + TabGroupMenu.colorStyles(group.color) + )) { + row.style.setProperty(property, value); + } + let button = doc.createXULElement("toolbarbutton"); button.setAttribute( "class", diff --git a/browser/components/tabbrowser/TabGroupMenu.sys.mjs b/browser/components/tabbrowser/TabGroupMenu.sys.mjs new file mode 100644 index 0000000000000..60c1add8c0ef8 --- /dev/null +++ b/browser/components/tabbrowser/TabGroupMenu.sys.mjs @@ -0,0 +1,248 @@ +/* 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/. */ + +/** + * Policy and actions shared by every "move tab to group" menu: the tab strip's + * tab context menu, the all tabs panel, and the Firefox View open tabs menu. + * Each caller owns its own markup and any surface-specific follow-up (which + * window to focus, which panel to close); everything that decides *what* the + * menu offers and *what happens* to the tabs lives here so the surfaces can't + * drift apart. + * + * Every entry point takes an array of tabs, since the tab strip acts on the + * multiselection while Firefox View acts on a single tab. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", + SessionStore: + "moz-src:///browser/components/sessionstore/SessionStore.sys.mjs", +}); + +/** + * Supported tab group colors, in the order the group editor shows them. + * + * @typedef {"blue"|"purple"|"cyan"|"orange"|"yellow"|"pink"|"green"|"gray"|"red"} TabGroupColor + */ + +/** @type {readonly TabGroupColor[]} */ +const COLORS = Object.freeze([ + "blue", + "purple", + "cyan", + "orange", + "yellow", + "pink", + "green", + "gray", + "red", +]); + +/** + * Used when a group's color isn't one we have chicklet variables for, so a + * menu item never renders without a chicklet color. + * + * @type {TabGroupColor} + */ +const FALLBACK_COLOR = "gray"; + +/** + * @param {MozTabbrowserTab[]} tabs + * @returns {object?} + */ +function tabbrowserFor(tabs) { + return tabs?.[0]?.documentGlobal?.gBrowser ?? null; +} + +/** + * The custom properties a tab group chicklet reads, resolved for one group + * color. Callers hand these to `style.setProperty` (chrome menus) or to lit's + * `styleMap` (Firefox View). + * + * The color is checked against COLORS rather than interpolated as-is, so a + * group record can't put arbitrary text into a CSS value. An unrecognized + * color falls back to FALLBACK_COLOR instead of producing no properties at + * all, so a group that somehow carries a color we don't know about still gets + * a visible chicklet in every menu. + * + * @param {TabGroupColor} color + * @returns {Record} + */ +function colorStyles(color) { + let name = COLORS.includes(color) ? color : FALLBACK_COLOR; + return { + "--tab-group-color": `var(--tab-group-${name})`, + "--tab-group-color-invert": `var(--tab-group-${name}-invert)`, + "--tab-group-color-pale": `var(--tab-group-${name}-pale)`, + "--tab-group-background-color": `var(--tab-group-${name})`, + }; +} + +/** + * The groups a "move tab to group" menu should offer for the given tabs. + * + * Open groups exclude the tabs' own group, but only when the tabs are all + * grouped and all in the same group: a mixed selection can still be collected + * into any of the groups it partly occupies. For a single tab this reduces to + * "every group except mine". + * + * Saved groups are only offered when the tabs' window may save tabs at all. + * + * @param {MozTabbrowserTab[]} tabs + * @returns {{ + * openGroups: MozTabbrowserTabGroup[], + * savedGroups: object[], + * groupCount: number, + * }} + * `groupCount` is how many distinct groups the tabs are spread across, which + * the tab strip also uses to label its ungroup items. + */ +function getGroupsToMoveTo(tabs) { + let gBrowser = tabbrowserFor(tabs); + if (!gBrowser) { + return { openGroups: [], savedGroups: [], groupCount: 0 }; + } + + // The filter removes the "null" group for ungrouped tabs. + let groupCount = new Set(tabs.map(tab => tab.group).filter(group => group)) + .size; + + let openGroups = gBrowser.getAllTabGroups({ sortByLastSeenActive: true }); + if (groupCount == 1) { + let groupToFilter = tabs[0].group; + if (groupToFilter && tabs.every(tab => tab.group)) { + openGroups = openGroups.filter(group => group !== groupToFilter); + } + } + + let savedGroups = []; + if ( + !lazy.PrivateBrowsingUtils.isWindowPrivate(tabs[0].documentGlobal) && + lazy.SessionStore.shouldSaveTabsToGroup(tabs) + ) { + savedGroups = lazy.SessionStore.getSavedTabGroups(); + } + + return { openGroups, savedGroups, groupCount }; +} + +/** + * Where a group created from `anchorTab` should be inserted. A pinned tab + * groups before the first unpinned tab, since groups can't hold pinned tabs. A + * tab that is already grouped groups before its group rather than inside it. + * + * @param {MozTabbrowserTab} anchorTab + * The tab the menu was opened on. + * @returns {MozTabbrowserTab|MozTabbrowserTabGroup|MozTabSplitViewWrapper?} + */ +function getNewGroupInsertBefore(anchorTab) { + let gBrowser = anchorTab.documentGlobal.gBrowser; + if (anchorTab.index < gBrowser.pinnedTabCount) { + let firstUnpinnedTab = gBrowser.tabs[gBrowser.pinnedTabCount]; + // Every tab is pinned, so there is nothing to insert before. + if (!firstUnpinnedTab) { + return null; + } + return firstUnpinnedTab.splitview ?? firstUnpinnedTab; + } + return anchorTab.group ?? anchorTab.splitview ?? anchorTab; +} + +/** + * Creates a group holding `tabs`, positioned relative to `anchorTab`. + * + * @param {MozTabbrowserTab[]} tabs + * @param {object} options + * @param {MozTabbrowserTab} [options.anchorTab] + * The tab the menu was opened on, which may not be `tabs[0]` when a + * multiselection is involved. Defaults to the first tab. + * @param {TabMetricsContext} options.metricsContext + * @returns {MozTabbrowserTabGroup} + */ +function createGroupFromTabs(tabs, { anchorTab = tabs[0], metricsContext }) { + return tabbrowserFor(tabs).addTabGroup(tabs, { + insertBefore: getNewGroupInsertBefore(anchorTab), + metricsContext, + }); +} + +/** + * Adds `tabs` to an open group, moving split views as a unit. + * + * @param {MozTabbrowserTab[]} tabs + * @param {MozTabbrowserTabGroup} group + * @param {TabMetricsContext} metricsContext + */ +function addTabsToGroup(tabs, group, metricsContext) { + let elementsToMove = new Set(); + for (let tab of tabs) { + elementsToMove.add(tab.splitview ?? tab); + } + group.addTabs(Array.from(elementsToMove), metricsContext); + group.documentGlobal.focus(); +} + +/** + * Removes `tabs` from whichever groups they are in. A split view leaves its + * group as a unit, so it is ungrouped once however many of its tabs are named. + * + * @param {MozTabbrowserTab[]} tabs + */ +function ungroupTabs(tabs) { + let gBrowser = tabbrowserFor(tabs); + if (!gBrowser) { + return; + } + let splitViewsSeen = new Set(); + for (let tab of tabs) { + if (!tab.splitview) { + gBrowser.ungroupTab(tab); + } else if (!splitViewsSeen.has(tab.splitview)) { + splitViewsSeen.add(tab.splitview); + gBrowser.ungroupSplitView(tab.splitview); + } + } +} + +/** + * Saves `tabs` into a closed group and closes them. Split views are saved as + * their individual tabs, since a saved group is just a list of tabs. + * + * @param {MozTabbrowserTab[]} tabs + * @param {string} savedGroupId + * @param {TabMetricsContext} metricsContext + */ +function addTabsToSavedGroup(tabs, savedGroupId, metricsContext) { + let tabsToSave = new Set(); + for (let tab of tabs) { + if (tab.splitview) { + for (let splitViewTab of tab.splitview.tabs) { + tabsToSave.add(splitViewTab); + } + } else { + tabsToSave.add(tab); + } + } + let gBrowser = tabbrowserFor(tabs); + let tabsToRemove = Array.from(tabsToSave); + lazy.SessionStore.addTabsToSavedGroup( + savedGroupId, + tabsToRemove, + metricsContext + ); + gBrowser.removeTabs(tabsToRemove, { animate: true, metricsContext }); +} + +export const TabGroupMenu = { + COLORS, + colorStyles, + getGroupsToMoveTo, + getNewGroupInsertBefore, + createGroupFromTabs, + addTabsToGroup, + addTabsToSavedGroup, + ungroupTabs, +}; diff --git a/browser/components/tabbrowser/content/tab-context-menu.js b/browser/components/tabbrowser/content/tab-context-menu.js index be3252ae5f20f..427cf08f45de1 100644 --- a/browser/components/tabbrowser/content/tab-context-menu.js +++ b/browser/components/tabbrowser/content/tab-context-menu.js @@ -591,35 +591,13 @@ var TabContextMenu = { let savedGroupsToMoveTo = []; if (TabContextMenu.Tabbrowser.prefs.tabGroupsEnabled) { - let selectedGroupCount = new Set( - // The filter removes the "null" group for ungrouped tabs. - this.contextTabs.map(t => t.group).filter(g => g) - ).size; - - openGroupsToMoveTo = gBrowser.getAllTabGroups({ - sortByLastSeenActive: true, - }); - - // Determine whether or not the "current" tab group should appear in the - // "move tab to group" context menu. - if (selectedGroupCount == 1) { - let groupToFilter = this.contextTabs[0].group; - if (groupToFilter && this.contextTabs.every(t => t.group)) { - openGroupsToMoveTo = openGroupsToMoveTo.filter( - group => group !== groupToFilter - ); - } - } - - // Populate the saved groups context menu - // Only enable in non-private windows, or if at least one of the tabs is - // considered saveable - if ( - !PrivateBrowsingUtils.isWindowPrivate(window) && - SessionStore.shouldSaveTabsToGroup(this.contextTabs) - ) { - savedGroupsToMoveTo = SessionStore.getSavedTabGroups(); - } + let { + openGroups, + savedGroups, + groupCount: selectedGroupCount, + } = this.TabGroupMenu.getGroupsToMoveTo(this.contextTabs); + openGroupsToMoveTo = openGroups; + savedGroupsToMoveTo = savedGroups; if (!openGroupsToMoveTo.length && !savedGroupsToMoveTo.length) { if (isAllSplitViewTabs) { @@ -1130,22 +1108,11 @@ var TabContextMenu = { item.classList.add("tab-group-icon-closed"); } - item.style.setProperty( - "--tab-group-color", - `var(--tab-group-${group.color})` - ); - item.style.setProperty( - "--tab-group-color-invert", - `var(--tab-group-${group.color}-invert)` - ); - item.style.setProperty( - "--tab-group-color-pale", - `var(--tab-group-${group.color}-pale)` - ); - item.style.setProperty( - "--tab-group-background-color", - `var(--tab-group-${group.color})` - ); + for (let [property, value] of Object.entries( + this.TabGroupMenu.colorStyles(group.color) + )) { + item.style.setProperty(property, value); + } return item; }, @@ -1276,21 +1243,8 @@ var TabContextMenu = { }, moveTabsToNewGroup() { - let insertBefore = this.contextTab; - if (insertBefore.index < gBrowser.pinnedTabCount) { - let firstUnpinnedTab = gBrowser.tabs[gBrowser.pinnedTabCount]; - if (firstUnpinnedTab.splitview) { - insertBefore = firstUnpinnedTab.splitview; - } else { - insertBefore = firstUnpinnedTab; - } - } else if (this.contextTab.group) { - insertBefore = this.contextTab.group; - } else if (this.contextTab.splitview) { - insertBefore = this.contextTab.splitview; - } - gBrowser.addTabGroup(this.contextTabs, { - insertBefore, + this.TabGroupMenu.createGroupFromTabs(this.contextTabs, { + anchorTab: this.contextTab, metricsContext: gBrowser.TabMetrics.userTriggeredContext( gBrowser.TabMetrics.METRIC_SOURCE.TAB_MENU ), @@ -1340,60 +1294,27 @@ var TabContextMenu = { * @param {MozTabbrowserTabGroup} group */ moveTabsToGroup(group) { - let elementsToMove = new Set(); - for (let tab of this.contextTabs) { - elementsToMove.add(tab.splitview ?? tab); - } - group.addTabs( - Array.from(elementsToMove.values()), + this.TabGroupMenu.addTabsToGroup( + this.contextTabs, + group, gBrowser.TabMetrics.userTriggeredContext( gBrowser.TabMetrics.METRIC_SOURCE.TAB_MENU ) ); - group.documentGlobal.focus(); }, addTabsToSavedGroup(groupId) { - let seen = new Set(); - let tabs = []; - for (let tab of this.contextTabs) { - if (tab.splitview) { - for (let splitTab of tab.splitview.tabs) { - if (!seen.has(splitTab)) { - seen.add(splitTab); - tabs.push(splitTab); - } - } - } else if (!seen.has(tab)) { - seen.add(tab); - tabs.push(tab); - } - } - SessionStore.addTabsToSavedGroup( + this.TabGroupMenu.addTabsToSavedGroup( + this.contextTabs, groupId, - tabs, gBrowser.TabMetrics.userTriggeredContext( gBrowser.TabMetrics.METRIC_SOURCE.TAB_MENU ) ); - gBrowser.removeTabs(tabs, { - animate: true, - metricsContext: gBrowser.TabMetrics.userTriggeredContext( - gBrowser.TabMetrics.METRIC_SOURCE.TAB_MENU - ), - }); }, ungroupTabsAndSplitViews() { - let splitViews = new Set(); - for (const tab of this.contextTabs) { - if (tab.splitview && !splitViews.has(tab.splitview)) { - splitViews.add(tab.splitview); - gBrowser.ungroupSplitView(tab.splitview); - } else if (!tab.splitview) { - gBrowser.ungroupTab(tab); - } - } + this.TabGroupMenu.ungroupTabs(this.contextTabs); }, moveTabsToSplitView() { @@ -1488,6 +1409,7 @@ ChromeUtils.defineESModuleGetters(TabContextMenu, { GenAI: "moz-src:///browser/components/genai/GenAI.sys.mjs", MenuSectionLayout: "resource:///modules/MenuSectionLayout.sys.mjs", Tabbrowser: "moz-src:///browser/components/tabbrowser/Tabbrowser.sys.mjs", + TabGroupMenu: "moz-src:///browser/components/tabbrowser/TabGroupMenu.sys.mjs", TabNotes: "moz-src:///browser/components/tabnotes/TabNotes.sys.mjs", TabStateFlusher: "moz-src:///browser/components/sessionstore/TabStateFlusher.sys.mjs", diff --git a/browser/components/tabbrowser/content/tabgroup-menu.js b/browser/components/tabbrowser/content/tabgroup-menu.js index 1902d2d40887a..49b4d80da2c42 100644 --- a/browser/components/tabbrowser/content/tabgroup-menu.js +++ b/browser/components/tabbrowser/content/tabgroup-menu.js @@ -7,6 +7,9 @@ // This is loaded into chrome windows with the subscript loader. Wrap in // a block to prevent accidentally leaking globals onto `window`. { + const { TabGroupMenu } = ChromeUtils.importESModule( + "moz-src:///browser/components/tabbrowser/TabGroupMenu.sys.mjs" + ); const { TabMetrics } = ChromeUtils.importESModule( "moz-src:///browser/components/tabbrowser/TabMetrics.sys.mjs" ); @@ -30,17 +33,7 @@ ); class MozTabbrowserTabGroupMenu extends MozXULElement { - static COLORS = [ - "blue", - "purple", - "cyan", - "orange", - "yellow", - "pink", - "green", - "gray", - "red", - ]; + static COLORS = TabGroupMenu.COLORS; static MESSAGE_IDS = { blue: "tab-group-editor-color-selector2-blue", diff --git a/browser/components/tabbrowser/content/tabgroup.mjs b/browser/components/tabbrowser/content/tabgroup.mjs index 282fdbdf2ec30..ddb13c8b31517 100644 --- a/browser/components/tabbrowser/content/tabgroup.mjs +++ b/browser/components/tabbrowser/content/tabgroup.mjs @@ -9,9 +9,7 @@ const { TabMetrics } = ChromeUtils.importESModule( ); /** - * Supported tab group colors. - * - * @typedef {"blue"|"purple"|"cyan"|"orange"|"yellow"|"pink"|"green"|"gray"|"red"} TabGroupColor + * @typedef {import("moz-src:///browser/components/tabbrowser/TabGroupMenu.sys.mjs").TabGroupColor} TabGroupColor */ export class MozTabbrowserTabGroup extends MozXULElement { diff --git a/browser/components/tabbrowser/moz.build b/browser/components/tabbrowser/moz.build index a475a943961c9..ea7ff99d2f71a 100644 --- a/browser/components/tabbrowser/moz.build +++ b/browser/components/tabbrowser/moz.build @@ -6,6 +6,8 @@ with Files("**"): BUG_COMPONENT = ("Firefox", "Tabbed Browser") with Files("GroupsList.sys.mjs"): BUG_COMPONENT = ("Firefox", "Tabbed Browser: Tab Groups") +with Files("TabGroupMenu.sys.mjs"): + BUG_COMPONENT = ("Firefox", "Tabbed Browser: Tab Groups") with Files("content/tabgroup.mjs"): BUG_COMPONENT = ("Firefox", "Tabbed Browser: Tab Groups") with Files("content/tabgroup-menu.js"): @@ -24,6 +26,7 @@ MOZ_SRC_FILES += [ "OpenInTabsUtils.sys.mjs", "SmartTabGrouping.sys.mjs", "Tabbrowser.sys.mjs", + "TabGroupMenu.sys.mjs", "TabMetrics.sys.mjs", "TabsList.sys.mjs", "TabUnloader.sys.mjs", diff --git a/browser/locales/en-US/browser/fxviewTabList.ftl b/browser/locales/en-US/browser/fxviewTabList.ftl index 02adbd04ba579..4941aeb00d497 100644 --- a/browser/locales/en-US/browser/fxviewTabList.ftl +++ b/browser/locales/en-US/browser/fxviewTabList.ftl @@ -56,6 +56,20 @@ fxviewtabrow-move-tab-end = Move to End .accesskey = E fxviewtabrow-move-tab-window = Move to New Window .accesskey = W +# Shown instead of fxviewtabrow-move-tab-group when there is no group to join. +# Accesskey avoids the Open tabs menu (v, P/p, M/m, L, n). +fxviewtabrow-move-tab-new-group = Add Tab to New Group + .accesskey = G +fxviewtabrow-move-tab-group = Add Tab to Group + .accesskey = G +# Accesskey is not G: that letter belongs to the parent item, and n/N belongs to Send. +fxviewtabrow-move-tab-group-new = New Group + .accesskey = R +fxviewtabrow-move-tab-group-closed = Closed Groups + .accesskey = O +fxviewtabrow-unnamed-group = Unnamed group +fxviewtabrow-ungroup-tab = Remove from Group + .accesskey = R fxviewtabrow-send-to-device = Send to Device .accesskey = n fxviewtabrow-send-to-mobile = Send to Mobile diff --git a/browser/themes/shared/jar.inc.mn b/browser/themes/shared/jar.inc.mn index 93d3583210731..567808ccb40da 100644 --- a/browser/themes/shared/jar.inc.mn +++ b/browser/themes/shared/jar.inc.mn @@ -319,6 +319,7 @@ skin/classic/browser/tabbrowser/tab-audio-blocked-circle-12.svg (../shared/tabbrowser/tab-audio-blocked-circle-12.svg) skin/classic/browser/tabbrowser/tab-drag-indicator.svg (../shared/tabbrowser/tab-drag-indicator.svg) skin/classic/browser/tabbrowser/tab-group-chicklet.svg (../shared/tabbrowser/tab-group-chicklet.svg) + skin/classic/browser/tabbrowser/tab-group-colors.css (../shared/tabbrowser/tab-group-colors.css) skin/classic/browser/tabbrowser/tab-groups.svg (../shared/tabbrowser/tab-groups.svg) skin/classic/browser/tabbrowser/tab-groups-list.css (../shared/tabbrowser/tab-groups-list.css) skin/classic/browser/tabbrowser/tab-hover-preview.css (../shared/tabbrowser/tab-hover-preview.css) diff --git a/browser/themes/shared/tabbrowser/tab-group-colors.css b/browser/themes/shared/tabbrowser/tab-group-colors.css new file mode 100644 index 0000000000000..27e8a9661e360 --- /dev/null +++ b/browser/themes/shared/tabbrowser/tab-group-colors.css @@ -0,0 +1,44 @@ +/* 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/. */ + +/* Pre-Nova tab group chicklet colors, the counterpart to the --tab-group-* + tokens tab.tokens.css generates for Nova. Imported by tabs.css for the tab + strip and by firefoxview.css for the Open tabs context menu, which doesn't + load tabs.css. + + These declarations are deliberately unlayered so they keep outranking the + layered Nova tokens, and are matched on :host as well as :root so they also + reach the Firefox View shadow roots that link firefoxview.css. */ +:root, +:host { + @media not -moz-pref("browser.nova.enabled") { + --tab-group-blue: light-dark(var(--color-blue-70), var(--color-blue-20)); + --tab-group-blue-invert: light-dark(var(--color-blue-0), var(--color-blue-70)); + --tab-group-blue-pale: var(--color-blue-0); + --tab-group-purple: light-dark(var(--color-purple-70), var(--color-purple-20)); + --tab-group-purple-invert: light-dark(var(--color-purple-0), var(--color-purple-70)); + --tab-group-purple-pale: var(--color-purple-0); + --tab-group-cyan: light-dark(var(--color-cyan-70), var(--color-cyan-20)); + --tab-group-cyan-invert: light-dark(var(--color-cyan-0), var(--color-cyan-70)); + --tab-group-cyan-pale: var(--color-cyan-0); + --tab-group-orange: light-dark(var(--color-orange-70), var(--color-orange-20)); + --tab-group-orange-invert: light-dark(var(--color-orange-0), var(--color-orange-70)); + --tab-group-orange-pale: var(--color-orange-0); + --tab-group-yellow: light-dark(var(--color-yellow-70), var(--color-yellow-20)); + --tab-group-yellow-invert: light-dark(var(--color-yellow-0), var(--color-yellow-70)); + --tab-group-yellow-pale: var(--color-yellow-0); + --tab-group-pink: light-dark(var(--color-pink-70), var(--color-pink-20)); + --tab-group-pink-invert: light-dark(var(--color-pink-0), var(--color-pink-70)); + --tab-group-pink-pale: var(--color-pink-0); + --tab-group-green: light-dark(var(--color-green-70), var(--color-green-20)); + --tab-group-green-invert: light-dark(var(--color-green-0), var(--color-green-70)); + --tab-group-green-pale: var(--color-green-0); + --tab-group-red: light-dark(var(--color-red-70), var(--color-red-20)); + --tab-group-red-invert: light-dark(var(--color-red-0), var(--color-red-70)); + --tab-group-red-pale: var(--color-red-0); + --tab-group-gray: light-dark(#5e6a77, #99a6b4); + --tab-group-gray-invert: light-dark(#f2f9ff, #5e6a77); + --tab-group-gray-pale: #f2f9ff; + } +} diff --git a/browser/themes/shared/tabbrowser/tabs.css b/browser/themes/shared/tabbrowser/tabs.css index 6de714f7ee4de..d2ed718dcc0e2 100644 --- a/browser/themes/shared/tabbrowser/tabs.css +++ b/browser/themes/shared/tabbrowser/tabs.css @@ -2,6 +2,8 @@ * 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/. */ +@import url("chrome://browser/skin/tabbrowser/tab-group-colors.css"); + :root { --tabstrip-inner-border: 1px solid color-mix(in srgb, currentColor 25%, transparent); --tab-group-suggestions-loading-animation-color-1: color-mix(in srgb, currentColor 5%, transparent); @@ -113,35 +115,8 @@ } } - @media not -moz-pref("browser.nova.enabled") { - --tab-group-blue: light-dark(var(--color-blue-70), var(--color-blue-20)); - --tab-group-blue-invert: light-dark(var(--color-blue-0), var(--color-blue-70)); - --tab-group-blue-pale: var(--color-blue-0); - --tab-group-purple: light-dark(var(--color-purple-70), var(--color-purple-20)); - --tab-group-purple-invert: light-dark(var(--color-purple-0), var(--color-purple-70)); - --tab-group-purple-pale: var(--color-purple-0); - --tab-group-cyan: light-dark(var(--color-cyan-70), var(--color-cyan-20)); - --tab-group-cyan-invert: light-dark(var(--color-cyan-0), var(--color-cyan-70)); - --tab-group-cyan-pale: var(--color-cyan-0); - --tab-group-orange: light-dark(var(--color-orange-70), var(--color-orange-20)); - --tab-group-orange-invert: light-dark(var(--color-orange-0), var(--color-orange-70)); - --tab-group-orange-pale: var(--color-orange-0); - --tab-group-yellow: light-dark(var(--color-yellow-70), var(--color-yellow-20)); - --tab-group-yellow-invert: light-dark(var(--color-yellow-0), var(--color-yellow-70)); - --tab-group-yellow-pale: var(--color-yellow-0); - --tab-group-pink: light-dark(var(--color-pink-70), var(--color-pink-20)); - --tab-group-pink-invert: light-dark(var(--color-pink-0), var(--color-pink-70)); - --tab-group-pink-pale: var(--color-pink-0); - --tab-group-green: light-dark(var(--color-green-70), var(--color-green-20)); - --tab-group-green-invert: light-dark(var(--color-green-0), var(--color-green-70)); - --tab-group-green-pale: var(--color-green-0); - --tab-group-red: light-dark(var(--color-red-70), var(--color-red-20)); - --tab-group-red-invert: light-dark(var(--color-red-0), var(--color-red-70)); - --tab-group-red-pale: var(--color-red-0); - --tab-group-gray: light-dark(#5e6a77, #99a6b4); - --tab-group-gray-invert: light-dark(#f2f9ff, #5e6a77); - --tab-group-gray-pale: #f2f9ff; - } + /* The pre-Nova --tab-group- values live in tab-group-colors.css, + imported at the top of this file, so Firefox View can share them. */ --tab-group-label-text-dark: var(--color-gray-100); /* 5px of padding-block are adding to .tabbrowser-tab */