commit fe2b6b18391318c1d64b7f03d8a1f2888d98e84d
parent 6584a19740044748058d2bc5c051b9282d628a70
Author: Kelly Cochrane <kcochrane@mozilla.com>
Date: Tue, 18 Nov 2025 17:24:23 +0000
Bug 1995543 - Add new context menu options for adding/removing split views to/from tab groups r=tabbrowser-reviewers,sthompson,fluent-reviewers,jsudiaman,bolsson
Differential Revision: https://phabricator.services.mozilla.com/D272093
Diffstat:
10 files changed, 377 insertions(+), 35 deletions(-)
diff --git a/browser/base/content/main-popupset.inc.xhtml b/browser/base/content/main-popupset.inc.xhtml
@@ -11,9 +11,11 @@
data-lazy-l10n-id="tab-context-move-tab-to-new-group"
data-l10n-args='{"tabCount": 1}'
hidden="true"/>
+ <menuitem id="context_moveSplitViewToNewGroup"
+ data-lazy-l10n-id="tab-context-move-split-view-to-new-group"
+ data-l10n-args='{"splitViewCount": 1}'
+ hidden="true"/>
<menu id="context_moveTabToGroup"
- data-lazy-l10n-id="tab-context-move-tab-to-group"
- data-l10n-args='{"tabCount": 1}'
hidden="true">
<menupopup id="context_moveTabToGroupPopupMenu">
<menuitem id="context_moveTabToGroupNewGroup"
@@ -32,6 +34,10 @@
data-lazy-l10n-id="tab-context-ungroup-tab"
data-l10n-args='{"groupCount": 1}'
hidden="true"/>
+ <menuitem id="context_ungroupSplitView"
+ data-lazy-l10n-id="tab-context-ungroup-tab"
+ data-l10n-args='{"groupCount": 1}'
+ hidden="true"/>
<menuitem id="context_moveTabToSplitView"
class="badge-new"
hidden="true"/>
diff --git a/browser/base/content/main-popupset.js b/browser/base/content/main-popupset.js
@@ -24,6 +24,12 @@ document.addEventListener(
case "context_ungroupTab":
TabContextMenu.ungroupTabs();
break;
+ case "context_moveSplitViewToNewGroup":
+ TabContextMenu.moveSplitViewToNewGroup();
+ break;
+ case "context_ungroupSplitView":
+ TabContextMenu.ungroupSplitViews();
+ break;
case "context_moveTabToSplitView":
TabContextMenu.moveTabsToSplitView();
break;
diff --git a/browser/components/tabbrowser/content/tabbrowser.js b/browser/components/tabbrowser/content/tabbrowser.js
@@ -3316,7 +3316,7 @@
* Adds a new tab group.
*
* @param {object[]} tabs
- * The set of tabs to include in the group.
+ * The set of tabs or split view to include in the group.
* @param {object} [options]
* @param {string} [options.id]
* Optionally assign an ID to the tab group. Useful when rebuilding an
@@ -3346,7 +3346,7 @@
* Defaults to "unknown".
*/
addTabGroup(
- tabs,
+ tabsAndSplitViews,
{
id = null,
color = null,
@@ -3357,8 +3357,17 @@
telemetryUserCreateSource = "unknown",
} = {}
) {
- if (!tabs?.length) {
- throw new Error("Cannot create tab group with zero tabs");
+ if (
+ !tabsAndSplitViews?.length ||
+ tabsAndSplitViews.some(
+ tabOrSplitView =>
+ !this.isTab(tabOrSplitView) &&
+ !this.isSplitViewWrapper(tabOrSplitView)
+ )
+ ) {
+ throw new Error(
+ "Cannot create tab group with zero tabs or split views"
+ );
}
if (!color) {
@@ -3383,7 +3392,7 @@
group,
insertBefore?.group ?? insertBefore
);
- group.addTabs(tabs);
+ group.addTabs(tabsAndSplitViews);
// Bail out if the group is empty at this point. This can happen if all
// provided tabs are pinned and therefore cannot be grouped.
@@ -3506,6 +3515,19 @@
);
}
+ ungroupSplitView(splitView) {
+ if (!this.isSplitViewWrapper(splitView)) {
+ return;
+ }
+
+ this.#handleTabMove(splitView, () =>
+ gBrowser.tabContainer.insertBefore(
+ splitView,
+ splitView.tabs[0].group.nextElementSibling
+ )
+ );
+ }
+
/**
* @param {MozTabbrowserTabGroup} group
* @param {object} [options]
@@ -6616,7 +6638,7 @@
* @param {MozTabbrowserTabGroup} aGroup
* @param {TabMetricsContext} [metricsContext]
*/
- moveTabToGroup(aTab, aGroup, metricsContext) {
+ moveTabToExistingGroup(aTab, aGroup, metricsContext) {
if (!this.isTab(aTab)) {
throw new Error("Can only move a tab into a tab group");
}
@@ -6649,6 +6671,32 @@
}
/**
+ *
+ * @param {MozSplitViewWrapper} aSplitView
+ * @param {MozTabbrowserTabGroup} aGroup
+ * @param {TabMetricsContext} [metricsContext]
+ */
+ moveSplitViewToExistingGroup(aSplitView, aGroup, metricsContext = null) {
+ if (!this.isSplitViewWrapper(aSplitView)) {
+ throw new Error("Can only move a split view into a tab group");
+ }
+ if (aSplitView.group && aSplitView.group.id === aGroup.id) {
+ return;
+ }
+
+ let splitViewTabs = aSplitView.tabs;
+ this.#handleTabMove(
+ aSplitView,
+ () => aGroup.appendChild(aSplitView),
+ metricsContext
+ );
+ for (const splitViewTab of splitViewTabs) {
+ this.removeFromMultiSelectedTabs(splitViewTab);
+ this.tabContainer._notifyBackgroundTab(splitViewTab);
+ }
+ }
+
+ /**
* @typedef {object} TabMoveState
* @property {number} tabIndex
* @property {number} [elementIndex]
@@ -6710,7 +6758,7 @@
}
/**
- * @param {MozTabbrowserTab|MozTabbrowserTabGroup} element
+ * @param {MozTabbrowserTab|MozTabbrowserTabGroup|MozTabSplitViewWrapper} element
* @param {function():void} moveActionCallback
* @param {TabMetricsContext} [metricsContext]
*/
@@ -9414,6 +9462,7 @@ var TabContextMenu = {
}
});
},
+ // eslint-disable-next-line complexity
updateContextMenu(aPopupMenu) {
let triggerTab =
aPopupMenu.triggerNode &&
@@ -9427,16 +9476,25 @@ var TabContextMenu = {
? gBrowser.selectedTabs
: [this.contextTab];
+ let splitViews = new Set();
// bug1973996: This call is not guaranteed to complete
// before the saved groups menu is populated
for (let tab of this.contextTabs) {
gBrowser.TabStateFlusher.flush(tab.linkedBrowser);
+
+ // Add unique split views for count info below
+ if (tab.splitview) {
+ splitViews.add(tab.splitview);
+ }
}
let disabled = gBrowser.tabs.length == 1;
let tabCountInfo = JSON.stringify({
tabCount: this.contextTabs.length,
});
+ let splitViewCountInfo = JSON.stringify({
+ splitViewCount: splitViews.size,
+ });
var menuItems = aPopupMenu.getElementsByAttribute(
"tbattr",
@@ -9486,6 +9544,15 @@ var TabContextMenu = {
"context_moveTabToGroup"
);
let contextUngroupTab = document.getElementById("context_ungroupTab");
+ let contextMoveSplitViewToNewGroup = document.getElementById(
+ "context_moveSplitViewToNewGroup"
+ );
+ let contextUngroupSplitView = document.getElementById(
+ "context_ungroupSplitView"
+ );
+ let isAllSplitViewTabs = this.contextTabs.every(
+ contextTab => contextTab.splitview
+ );
if (gBrowser._tabGroupsEnabled) {
let selectedGroupCount = new Set(
@@ -9520,13 +9587,43 @@ var TabContextMenu = {
}
if (!openGroupsToMoveTo.length && !savedGroupsToMoveTo.length) {
- contextMoveTabToGroup.hidden = true;
- contextMoveTabToNewGroup.hidden = false;
- contextMoveTabToNewGroup.setAttribute("data-l10n-args", tabCountInfo);
+ if (isAllSplitViewTabs) {
+ contextMoveTabToGroup.hidden = true;
+ contextMoveTabToNewGroup.hidden = true;
+ contextMoveSplitViewToNewGroup.hidden = false;
+ contextMoveSplitViewToNewGroup.setAttribute(
+ "data-l10n-args",
+ splitViewCountInfo
+ );
+ } else {
+ contextMoveTabToGroup.hidden = true;
+ contextMoveSplitViewToNewGroup.hidden = true;
+ contextMoveTabToNewGroup.hidden = false;
+ contextMoveTabToNewGroup.setAttribute("data-l10n-args", tabCountInfo);
+ }
} else {
- contextMoveTabToNewGroup.hidden = true;
- contextMoveTabToGroup.hidden = false;
- contextMoveTabToGroup.setAttribute("data-l10n-args", tabCountInfo);
+ if (isAllSplitViewTabs) {
+ contextMoveTabToNewGroup.hidden = true;
+ contextMoveSplitViewToNewGroup.hidden = true;
+ contextMoveTabToGroup.hidden = false;
+ contextMoveTabToGroup.setAttribute(
+ "data-l10n-id",
+ "tab-context-move-split-view-to-group"
+ );
+ contextMoveTabToGroup.setAttribute(
+ "data-l10n-args",
+ splitViewCountInfo
+ );
+ } else {
+ contextMoveTabToNewGroup.hidden = true;
+ contextMoveSplitViewToNewGroup.hidden = true;
+ contextMoveTabToGroup.hidden = false;
+ contextMoveTabToGroup.setAttribute(
+ "data-l10n-id",
+ "tab-context-move-tab-to-group"
+ );
+ contextMoveTabToGroup.setAttribute("data-l10n-args", tabCountInfo);
+ }
const openGroupsMenu = contextMoveTabToGroup.querySelector("menupopup");
openGroupsMenu
@@ -9566,15 +9663,24 @@ var TabContextMenu = {
}
}
- contextUngroupTab.hidden = !selectedGroupCount;
let groupInfo = JSON.stringify({
groupCount: selectedGroupCount,
});
- contextUngroupTab.setAttribute("data-l10n-args", groupInfo);
+ if (isAllSplitViewTabs) {
+ contextUngroupSplitView.hidden = !selectedGroupCount;
+ contextUngroupTab.hidden = true;
+ contextUngroupSplitView.setAttribute("data-l10n-args", groupInfo);
+ } else {
+ contextUngroupTab.hidden = !selectedGroupCount;
+ contextUngroupSplitView.hidden = true;
+ contextUngroupTab.setAttribute("data-l10n-args", groupInfo);
+ }
} else {
contextMoveTabToNewGroup.hidden = true;
contextMoveTabToGroup.hidden = true;
contextUngroupTab.hidden = true;
+ contextMoveSplitViewToNewGroup.hidden = true;
+ contextUngroupSplitView.hidden = true;
}
// Split View
@@ -10003,6 +10109,38 @@ var TabContextMenu = {
gTabsPanel.hideAllTabsPanel();
},
+ moveSplitViewToNewGroup() {
+ let insertBefore = this.contextTab;
+ if (insertBefore._tPos < gBrowser.pinnedTabCount) {
+ insertBefore = gBrowser.tabs[gBrowser.pinnedTabCount];
+ } else if (this.contextTab.group) {
+ insertBefore = this.contextTab.group;
+ } else if (this.contextTab.splitview) {
+ insertBefore = this.contextTab.splitview;
+ }
+ let tabsAndSplitViews = [];
+ for (const contextTab of this.contextTabs) {
+ if (contextTab.splitView) {
+ if (!tabsAndSplitViews.includes(contextTab.splitView)) {
+ tabsAndSplitViews.push(contextTab.splitView);
+ }
+ } else {
+ tabsAndSplitViews.push(contextTab);
+ }
+ }
+ gBrowser.addTabGroup(tabsAndSplitViews, {
+ insertBefore,
+ isUserTriggered: true,
+ telemetryUserCreateSource: "tab_menu",
+ });
+ gBrowser.selectedTab = this.contextTabs[0];
+
+ // When using the tab context menu to create a group from the all tabs
+ // panel, make sure we close that panel so that it doesn't obscure the tab
+ // group creation panel.
+ gTabsPanel.hideAllTabsPanel();
+ },
+
/**
* @param {MozTabbrowserTabGroup} group
*/
@@ -10033,6 +10171,16 @@ var TabContextMenu = {
}
},
+ ungroupSplitViews() {
+ let splitViews = new Set();
+ for (const tab of this.contextTabs) {
+ if (!splitViews.has(tab.splitview)) {
+ splitViews.add(tab.splitview);
+ gBrowser.ungroupSplitView(tab.splitview);
+ }
+ }
+ },
+
moveTabsToSplitView() {
let insertBefore = this.contextTabs.includes(gBrowser.selectedTab)
? gBrowser.selectedTab
diff --git a/browser/components/tabbrowser/content/tabgroup.js b/browser/components/tabbrowser/content/tabgroup.js
@@ -546,23 +546,31 @@
/**
* add tabs to the group
*
- * @param {MozTabbrowserTab[]} tabs
+ * @param {MozTabbrowserTab[] | MozSplitViewWrapper} tabsOrSplitViews
* @param {TabMetricsContext} [metricsContext]
* Optional context to record for metrics purposes.
*/
- addTabs(tabs, metricsContext) {
- for (let tab of tabs) {
- if (tab.pinned) {
- tab.ownerGlobal.gBrowser.unpinTab(tab);
+ addTabs(tabsOrSplitViews, metricsContext = null) {
+ for (let tabOrSplitView of tabsOrSplitViews) {
+ if (gBrowser.isSplitViewWrapper(tabOrSplitView)) {
+ gBrowser.moveSplitViewToExistingGroup(
+ tabOrSplitView,
+ this,
+ metricsContext
+ );
+ } else {
+ if (tabOrSplitView.pinned) {
+ tabOrSplitView.ownerGlobal.gBrowser.unpinTab(tabOrSplitView);
+ }
+ let tabToMove =
+ this.ownerGlobal === tabOrSplitView.ownerGlobal
+ ? tabOrSplitView
+ : gBrowser.adoptTab(tabOrSplitView, {
+ tabIndex: gBrowser.tabs.at(-1)._tPos + 1,
+ selectTab: tabOrSplitView.selected,
+ });
+ gBrowser.moveTabToExistingGroup(tabToMove, this, metricsContext);
}
- let tabToMove =
- this.ownerGlobal === tab.ownerGlobal
- ? tab
- : gBrowser.adoptTab(tab, {
- tabIndex: gBrowser.tabs.at(-1)._tPos + 1,
- selectTab: tab.selected,
- });
- gBrowser.moveTabToGroup(tabToMove, this, metricsContext);
}
this.#lastAddedTo = Date.now();
}
diff --git a/browser/components/tabbrowser/content/tabsplitview.js b/browser/components/tabbrowser/content/tabsplitview.js
@@ -22,6 +22,15 @@
}
/**
+ * @returns {MozTabbrowserGroup}
+ */
+ get group() {
+ return gBrowser.isTabGroup(this.parentElement)
+ ? this.parentElement
+ : null;
+ }
+
+ /**
* @param {boolean} val
*/
set hasActiveTab(val) {
diff --git a/browser/components/tabbrowser/test/browser/tabs/browser_tab_groups.js b/browser/components/tabbrowser/test/browser/tabs/browser_tab_groups.js
@@ -42,6 +42,25 @@ add_task(async function test_tabGroupCreateAndAddTab() {
await removeTabGroup(group);
});
+add_task(async function test_tabGroupCreateAndAddSplitView() {
+ let tab1 = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ let tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank");
+
+ let splitview = gBrowser.addTabSplitView([tab1, tab2]);
+
+ Assert.ok(splitview.splitViewId, "split view has id");
+ Assert.ok(
+ splitview.tabs.includes(tab1) && splitview.tabs.includes(tab2),
+ "tab1 and tab2 are in split view"
+ );
+
+ let group = gBrowser.addTabGroup([splitview]);
+
+ Assert.equal(group.tabs.length, 2, "group has 2 tabs");
+
+ await removeTabGroup(group);
+});
+
add_task(async function test_tabGroupCreateAndAddTabAtPosition() {
let tabs = createManyTabs(10);
let tabToGroup = tabs[5];
@@ -157,7 +176,7 @@ add_task(async function test_tabGroupCollapseAndExpand() {
group.collapsed = true;
Assert.ok(group.collapsed, "group is collapsed via API");
- gBrowser.moveTabToGroup(tab2, group);
+ gBrowser.moveTabToExistingGroup(tab2, group);
Assert.ok(
group.collapsed,
"group stays collapsed after moving inactive tab into group"
diff --git a/browser/components/tabbrowser/test/browser/tabs/browser_tab_groups_tab_interactions_telemetry.js b/browser/components/tabbrowser/test/browser/tabs/browser_tab_groups_tab_interactions_telemetry.js
@@ -146,7 +146,9 @@ add_task(async function test_tabInteractionsBasic() {
);
let tab1 = await addTab();
await assertMetricEmpty("add");
- window.gBrowser.moveTabToGroup(tab1, group, { isUserTriggered: true });
+ window.gBrowser.moveTabToExistingGroup(tab1, group, {
+ isUserTriggered: true,
+ });
await assertMetricFoundFor("add");
info(
diff --git a/browser/components/tabbrowser/test/browser/tabs/browser_tab_preview.js b/browser/components/tabbrowser/test/browser/tabs/browser_tab_preview.js
@@ -860,7 +860,7 @@ add_task(async function tabGroupPanelUpdatesTests() {
newTab = await addTabTo(gBrowser, "about:robots");
let tabGroupedEvent = BrowserTestUtils.waitForEvent(group, "TabGrouped");
- gBrowser.moveTabToGroup(newTab, group);
+ gBrowser.moveTabToExisitingGroup(newTab, group);
await tabGroupedEvent;
Assert.equal(panelContent.children.length, 2, "Panel has two tabs");
diff --git a/browser/components/tabbrowser/test/browser/tabs/browser_tab_splitview_contextmenu.js b/browser/components/tabbrowser/test/browser/tabs/browser_tab_splitview_contextmenu.js
@@ -51,18 +51,29 @@ const withTabMenu = async function (tab, callback) {
"context_moveTabToSplitView"
);
const unsplitTabItem = document.getElementById("context_separateSplitView");
+ const addSplitViewToNewGroup = document.getElementById(
+ "context_moveSplitViewToNewGroup"
+ );
+ const removeSplitViewFromGroup = document.getElementById(
+ "context_ungroupSplitView"
+ );
let contextMenuHidden = BrowserTestUtils.waitForPopupEvent(
tabContextMenu,
"hidden"
);
- await callback(moveTabToNewSplitViewItem, unsplitTabItem);
+ await callback(
+ moveTabToNewSplitViewItem,
+ unsplitTabItem,
+ addSplitViewToNewGroup,
+ removeSplitViewFromGroup
+ );
tabContextMenu.hidePopup();
info("Hide popup");
return await contextMenuHidden;
};
-add_task(async function test_tabGroupContextMenuMoveTabsToNewGroup() {
+add_task(async function test_contextMenuMoveTabsToNewSplitView() {
await SpecialPowers.pushPrefEnv({
set: [["browser.tabs.splitView.enabled", true]],
});
@@ -367,3 +378,121 @@ add_task(async function test_tabGroupContextMenuMoveTabsToNewGroup() {
BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
}
});
+
+add_task(async function test_contextMenuAddSplitViewToNewTabGroup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.splitView.enabled", true]],
+ });
+ const tab1 = await addTabAndLoadBrowser();
+ const tab2 = await addTabAndLoadBrowser();
+ let tabContainer = document.getElementById("tabbrowser-arrowscrollbox");
+
+ gBrowser.addTabSplitView([tab1, tab2]);
+
+ await BrowserTestUtils.waitForMutationCondition(
+ tabContainer,
+ { children: true },
+ () => {
+ return (
+ Array.from(tabContainer.children).some(
+ tabChild => tabChild.tagName === "tab-split-view-wrapper"
+ ) &&
+ tab1.splitview &&
+ tab2.splitview
+ );
+ },
+ "Split view has been added"
+ );
+ info("Split view has been added");
+
+ let splitview = tab1.splitview;
+
+ Assert.ok(tab1.splitview, "tab is in split view");
+ [tab1, tab2].forEach((t, idx) => {
+ Assert.equal(t.splitview, splitview, `tabs[${idx}] is in split view`);
+ });
+
+ EventUtils.synthesizeMouseAtCenter(tab1, {});
+
+ let tabToClick = tab2;
+ await withTabMenu(
+ tabToClick,
+ async (
+ moveTabToNewSplitViewItem,
+ unsplitTabItem,
+ addSplitViewToNewGroupItem
+ ) => {
+ await BrowserTestUtils.waitForMutationCondition(
+ addSplitViewToNewGroupItem,
+ { attributes: true },
+ () =>
+ !addSplitViewToNewGroupItem.hidden &&
+ addSplitViewToNewGroupItem.textContent ===
+ "Add Split View to New Group",
+ "addSplitViewToNewGroupItem is visible and has the expected label"
+ );
+
+ info("Click menu option to add split view to new group");
+ addSplitViewToNewGroupItem.click();
+ }
+ );
+
+ await BrowserTestUtils.waitForMutationCondition(
+ tabContainer,
+ { children: true },
+ () => {
+ return Array.from(tabContainer.children).some(
+ tabChild => tabChild.tagName === "tab-group"
+ );
+ },
+ "Split view has been added to a new tab group"
+ );
+ Assert.ok(
+ tab1.splitview.group && tab2.splitview.group,
+ "Split view is within a tab group"
+ );
+ info("Split view has been added to new group");
+
+ await withTabMenu(
+ tabToClick,
+ async (
+ moveTabToNewSplitViewItem,
+ unsplitTabItem,
+ addSplitViewToNewGroupItem,
+ removeSplitViewFromGroupItem
+ ) => {
+ await BrowserTestUtils.waitForMutationCondition(
+ removeSplitViewFromGroupItem,
+ { attributes: true },
+ () =>
+ !removeSplitViewFromGroupItem.hidden &&
+ removeSplitViewFromGroupItem.textContent === "Remove from Group",
+ "removeSplitViewFromGroupItem is visible and has the expected label"
+ );
+
+ info("Click menu option to remove split view from group");
+ removeSplitViewFromGroupItem.click();
+ }
+ );
+
+ await BrowserTestUtils.waitForMutationCondition(
+ tabContainer,
+ { children: true },
+ () => {
+ return !Array.from(tabContainer.children).some(
+ tabChild => tabChild.tagName === "tab-group"
+ );
+ },
+ "Split view has been removed from tab group"
+ );
+ Assert.ok(
+ !tab1.splitview.group && !tab2.splitview.group,
+ "Split view is no longer within a tab group"
+ );
+ info("Split view has been removed from group");
+
+ splitview.close();
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs.at(-1));
+ }
+});
diff --git a/browser/locales/en-US/browser/tabbrowser.ftl b/browser/locales/en-US/browser/tabbrowser.ftl
@@ -276,6 +276,7 @@ tab-context-unnamed-group =
## Variables:
## $tabCount (Number): the number of tabs that are affected by the action.
+## $splitViewCount (Number): the number of split views that are affected by the action.
# When a tab group containing the active tab is collapsed, the active tab
# remains visible. An indicator appears at the end of the group showing the
@@ -301,6 +302,20 @@ tab-context-move-tab-to-group =
*[other] Add Tabs to Group
}
.accesskey = G
+tab-context-move-split-view-to-new-group =
+ .label =
+ { $splitViewCount ->
+ [1] Add Split View to New Group
+ *[other] Add Split Views to New Group
+ }
+ .accesskey = G
+tab-context-move-split-view-to-group =
+ .label =
+ { $splitViewCount ->
+ [1] Add Split View to Group
+ *[other] Add Split Views to Group
+ }
+ .accesskey = G
##