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``;
+ }
+
+ 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 */