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 */