commit 0f7b9bd615a8bda5995a0e58a71728ab76c27d58
parent 06a91d99ed1ceeffefec4424f858427cb3ff603f
Author: Kelly Cochrane <kcochrane@mozilla.com>
Date: Mon, 13 Oct 2025 18:29:04 +0000
Bug 1986946 - Add context menu entry point for split view, and handle the single tab and multi-selected cases r=tabbrowser-reviewers,fluent-reviewers,jsudiaman,flod,sthompson,sclements,desktop-theme-reviewers,sfoster
Differential Revision: https://phabricator.services.mozilla.com/D266186
Diffstat:
9 files changed, 453 insertions(+), 37 deletions(-)
diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js
@@ -2633,6 +2633,9 @@ pref("browser.tabs.fadeOutExplicitlyUnloadedTabs", true);
// they are explicitly unloaded) are faded out in the tab bar.
pref("browser.tabs.fadeOutUnloadedTabs", false);
+// Whether tabs can be "split" or displayed side by side at once.
+pref("browser.tabs.splitView.enabled", false);
+
// If true, unprivileged extensions may use experimental APIs on
// nightly and developer edition.
pref("extensions.experiments.enabled", false);
diff --git a/browser/base/content/main-popupset.inc.xhtml b/browser/base/content/main-popupset.inc.xhtml
@@ -32,6 +32,13 @@
data-lazy-l10n-id="tab-context-ungroup-tab"
data-l10n-args='{"groupCount": 1}'
hidden="true"/>
+ <menuitem id="context_moveTabToSplitView"
+ class="badge-new"
+ hidden="true"/>
+ <menuitem id="context_separateSplitView"
+ class="badge-new"
+ data-lazy-l10n-id="tab-context-separate-split-view"
+ hidden="true"/>
<menuseparator/>
<menuitem id="context_reloadTab" data-lazy-l10n-id="reload-tab"/>
<menuitem id="context_reloadSelectedTabs" data-lazy-l10n-id="reload-tabs" 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_moveTabToSplitView":
+ TabContextMenu.moveTabsToSplitView();
+ break;
+ case "context_separateSplitView":
+ TabContextMenu.unsplitTabs();
+ break;
case "context_reloadTab":
gBrowser.reloadTab(TabContextMenu.contextTab);
break;
@@ -521,6 +527,9 @@ document.addEventListener(
case "bhTooltip":
BookmarksEventHandler.fillInBHTooltip(event.target, event);
break;
+ case "tabContextMenu":
+ TabContextMenu.addNewBadge();
+ break;
}
});
diff --git a/browser/components/tabbrowser/content/tab.js b/browser/components/tabbrowser/content/tab.js
@@ -374,10 +374,7 @@
}
get group() {
- if (this.parentElement?.tagName == "tab-group") {
- return this.parentElement;
- }
- return null;
+ return this.closest("tab-group");
}
get splitview() {
diff --git a/browser/components/tabbrowser/content/tabbrowser.js b/browser/components/tabbrowser/content/tabbrowser.js
@@ -3225,9 +3225,12 @@
return;
}
- for (const tab of splitview.tabs) {
- this.#handleTabMove(tab, () =>
- gBrowser.tabContainer.insertBefore(tab, splitview.nextElementSibling)
+ for (let i = splitview.tabs.length - 1; i >= 0; i--) {
+ this.#handleTabMove(splitview.tabs[i], () =>
+ gBrowser.tabContainer.insertBefore(
+ splitview.tabs[i],
+ splitview.nextElementSibling
+ )
);
}
splitview.remove();
@@ -7408,7 +7411,7 @@
}
getTabPids(tab) {
- if (!tab.linkedBrowser) {
+ if (!tab?.linkedBrowser) {
return [];
}
@@ -9490,6 +9493,34 @@ var TabContextMenu = {
contextUngroupTab.hidden = true;
}
+ // Split View
+ let splitViewEnabled = Services.prefs.getBoolPref(
+ "browser.tabs.splitView.enabled",
+ false
+ );
+ let contextMoveTabToNewSplitView = document.getElementById(
+ "context_moveTabToSplitView"
+ );
+ let contextSeparateSplitView = document.getElementById(
+ "context_separateSplitView"
+ );
+ let hasSplitViewTab = this.contextTabs.some(tab => tab.splitview);
+ contextMoveTabToNewSplitView.hidden = !splitViewEnabled || hasSplitViewTab;
+ contextSeparateSplitView.hidden = !splitViewEnabled || !hasSplitViewTab;
+ if (splitViewEnabled) {
+ contextMoveTabToNewSplitView.removeAttribute("data-l10n-id");
+ contextMoveTabToNewSplitView.setAttribute(
+ "data-l10n-id",
+ this.contextTabs.length < 2
+ ? "tab-context-add-split-view"
+ : "tab-context-open-in-split-view"
+ );
+
+ let pinnedTabs = this.contextTabs.filter(t => t.pinned);
+ contextMoveTabToNewSplitView.disabled =
+ this.contextTabs.length > 2 || pinnedTabs.length;
+ }
+
// Only one of Reload_Tab/Reload_Selected_Tabs should be visible.
document.getElementById("context_reloadTab").hidden = this.multiselected;
document.getElementById("context_reloadSelectedTabs").hidden =
@@ -9913,6 +9944,55 @@ var TabContextMenu = {
gBrowser.ungroupTab(this.contextTabs[i]);
}
},
+
+ moveTabsToSplitView() {
+ let insertBefore = this.contextTabs.includes(gBrowser.selectedTab)
+ ? gBrowser.selectedTab
+ : this.contextTabs[0];
+ let tabsToAdd = this.contextTabs;
+
+ // Ensure selected tab is always first in split view
+ const selectedTabIndex = tabsToAdd.indexOf(gBrowser.selectedTab);
+ if (selectedTabIndex > -1 && selectedTabIndex != 0) {
+ const [removed] = tabsToAdd.splice(selectedTabIndex, 1);
+ tabsToAdd.unshift(removed);
+ }
+
+ let newTab = null;
+ if (this.contextTabs.length < 2) {
+ // Open new tab to split with context tab
+ newTab = gBrowser.addTrustedTab(BROWSER_NEW_TAB_URL);
+ tabsToAdd = [this.contextTabs[0], newTab];
+ }
+
+ gBrowser.addTabSplitView(tabsToAdd, {
+ insertBefore,
+ });
+
+ if (newTab) {
+ gBrowser.selectedTab = newTab;
+ }
+ },
+
+ unsplitTabs() {
+ const splitviews = new Set(
+ this.contextTabs.map(tab => tab.splitview).filter(Boolean)
+ );
+ splitviews.forEach(splitview => gBrowser.unsplitTabs(splitview));
+ },
+
+ addNewBadge() {
+ let badgeNewMenuItems = document.querySelectorAll(
+ "#tabContextMenu menuitem.badge-new"
+ );
+
+ badgeNewMenuItems.forEach(badgedMenuItem => {
+ badgedMenuItem.setAttribute(
+ "badge",
+ gBrowser.tabLocalization.formatValueSync("tab-context-badge-new")
+ );
+ });
+ },
};
ChromeUtils.defineESModuleGetters(TabContextMenu, {
diff --git a/browser/components/tabbrowser/content/tabgroup.js b/browser/components/tabbrowser/content/tabgroup.js
@@ -186,32 +186,7 @@
tab.setAttribute("aria-setsize", tabCount);
});
this.hasActiveTab = hasActiveTab;
-
- // When a group containing the active tab is collapsed,
- // the overflow count displays the number of additional tabs
- // in the group adjacent to the active tab.
- let overflowCountLabel = this.overflowContainer.querySelector(
- ".tab-group-overflow-count"
- );
- if (tabCount > 1) {
- gBrowser.tabLocalization
- .formatValue("tab-group-overflow-count", {
- tabCount: tabCount - 1,
- })
- .then(result => (overflowCountLabel.textContent = result));
- gBrowser.tabLocalization
- .formatValue("tab-group-overflow-count-tooltip", {
- tabCount: tabCount - 1,
- })
- .then(result => {
- overflowCountLabel.setAttribute("tooltiptext", result);
- overflowCountLabel.setAttribute("aria-description", result);
- });
- this.toggleAttribute("hasmultipletabs", true);
- } else {
- overflowCountLabel.textContent = "";
- this.toggleAttribute("hasmultipletabs", false);
- }
+ this.#updateOverflowLabel();
}
for (const mutation of mutations) {
for (const addedNode of mutation.addedNodes) {
@@ -421,11 +396,51 @@
}
}
+ #updateOverflowLabel() {
+ // When a group containing the active tab is collapsed,
+ // the overflow count displays the number of additional tabs
+ // in the group adjacent to the active tab.
+ let overflowCountLabel = this.overflowContainer.querySelector(
+ ".tab-group-overflow-count"
+ );
+ let tabs = this.tabs;
+ let tabCount = tabs.length;
+ const overflowOffset =
+ this.hasActiveTab && gBrowser.selectedTab.splitview ? 2 : 1;
+
+ if (tabCount > 1) {
+ this.toggleAttribute("hasmultipletabs", true);
+ } else {
+ overflowCountLabel.textContent = "";
+ this.toggleAttribute("hasmultipletabs", false);
+ }
+
+ gBrowser.tabLocalization
+ .formatValue("tab-group-overflow-count", {
+ tabCount: tabCount - overflowOffset,
+ })
+ .then(result => (overflowCountLabel.textContent = result));
+ gBrowser.tabLocalization
+ .formatValue("tab-group-overflow-count-tooltip", {
+ tabCount: tabCount - overflowOffset,
+ })
+ .then(result => {
+ overflowCountLabel.setAttribute("tooltiptext", result);
+ overflowCountLabel.setAttribute("aria-description", result);
+ });
+ }
+
/**
* @returns {MozTabbrowserTab[]}
*/
get tabs() {
- return Array.from(this.children).filter(node => node.matches("tab"));
+ let childrenArray = Array.from(this.children);
+ for (let i = childrenArray.length - 1; i >= 0; i--) {
+ if (childrenArray[i].tagName == "tab-split-view-wrapper") {
+ childrenArray.splice(i, 1, ...childrenArray[i].tabs);
+ }
+ }
+ return childrenArray.filter(node => node.matches("tab"));
}
/**
@@ -622,6 +637,8 @@
if (previousTab.group === this) {
this.#updateTabAriaHidden(previousTab);
}
+
+ this.#updateOverflowLabel();
}
/**
diff --git a/browser/components/tabbrowser/test/browser/tabs/browser_tab_splitview.js b/browser/components/tabbrowser/test/browser/tabs/browser_tab_splitview.js
@@ -4,7 +4,7 @@
add_setup(async function () {
await SpecialPowers.pushPrefEnv({
- set: [["sidebar.verticalTabs", false]],
+ set: [["sidebar.verticalTabs", true]],
});
});
@@ -13,6 +13,7 @@ registerCleanupFunction(async function () {
set: [
["sidebar.verticalTabs", false],
["sidebar.revamp", false],
+ ["browser.tabs.splitView.enabled", false],
],
});
});
@@ -51,6 +52,43 @@ function dragSplitter(deltaX, splitter) {
AccessibilityUtils.resetEnv();
}
+/**
+ * @param {MozTabbrowserTab} tab
+ * @param {function(splitViewMenuItem: Element, unsplitMenuItem: Element) => Promise<void>} callback
+ */
+const withTabMenu = async function (tab, callback) {
+ const tabContextMenu = document.getElementById("tabContextMenu");
+ Assert.equal(
+ tabContextMenu.state,
+ "closed",
+ "context menu is initially closed"
+ );
+ const contextMenuShown = BrowserTestUtils.waitForPopupEvent(
+ tabContextMenu,
+ "shown"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(
+ tab,
+ { type: "contextmenu", button: 2 },
+ window
+ );
+ await contextMenuShown;
+
+ const moveTabToNewSplitViewItem = document.getElementById(
+ "context_moveTabToSplitView"
+ );
+ const unsplitTabItem = document.getElementById("context_separateSplitView");
+
+ let contextMenuHidden = BrowserTestUtils.waitForPopupEvent(
+ tabContextMenu,
+ "hidden"
+ );
+ await callback(moveTabToNewSplitViewItem, unsplitTabItem);
+ tabContextMenu.hidePopup();
+ return contextMenuHidden;
+};
+
add_task(async function test_splitViewCreateAndAddTabs() {
let tab1 = BrowserTestUtils.addTab(gBrowser, "about:blank");
let tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank");
@@ -239,3 +277,222 @@ add_task(async function test_resize_split_view_panels() {
splitView.close();
});
+
+add_task(async function test_tabGroupContextMenuMoveTabsToNewGroup() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.splitView.enabled", true]],
+ });
+ const tab1 = await addTabAndLoadBrowser();
+ const tab2 = await addTabAndLoadBrowser();
+ const tab3 = await addTabAndLoadBrowser();
+ let tabs = [tab1, tab2, tab3];
+
+ // Click the first tab in our test split view to make sure the default tab at the
+ // start of the tab strip is deselected
+ EventUtils.synthesizeMouseAtCenter(tab1, {});
+
+ tabs.forEach(t => {
+ EventUtils.synthesizeMouseAtCenter(
+ t,
+ { ctrlKey: true, metaKey: true },
+ window
+ );
+ });
+
+ let tabToClick = tab3;
+ await withTabMenu(
+ tabToClick,
+ async (moveTabToNewSplitViewItem, unsplitTabItem) => {
+ await BrowserTestUtils.waitForMutationCondition(
+ moveTabToNewSplitViewItem,
+ { attributes: true },
+ async () => {
+ return (
+ !moveTabToNewSplitViewItem.hidden &&
+ moveTabToNewSplitViewItem.disabled
+ );
+ },
+ "moveTabToNewSplitViewItem is visible and disabled"
+ );
+ await BrowserTestUtils.waitForMutationCondition(
+ unsplitTabItem,
+ { attributes: true },
+ async () => {
+ return unsplitTabItem.hidden;
+ },
+ "unsplitTabItem is hidden"
+ );
+ }
+ );
+
+ // Test opening split view from 2 non-consecutive tabs
+ let tabContainer = document.getElementById("tabbrowser-arrowscrollbox");
+ let tab3Index = Array.from(tabContainer.children).indexOf(tab3);
+ EventUtils.synthesizeMouseAtCenter(tab3, {});
+ tabToClick = tab3;
+
+ [tabs[0], tabs[2]].forEach(t => {
+ gBrowser.addToMultiSelectedTabs(t);
+ ok(t.multiselected, "added tab to mutliselection");
+ });
+
+ await withTabMenu(
+ tabToClick,
+ async (moveTabToNewSplitViewItem, unsplitTabItem) => {
+ await BrowserTestUtils.waitForMutationCondition(
+ moveTabToNewSplitViewItem,
+ { attributes: true },
+ async () => {
+ return (
+ !moveTabToNewSplitViewItem.hidden &&
+ !moveTabToNewSplitViewItem.disabled
+ );
+ },
+ "moveTabToNewSplitViewItem is visible and not disabled"
+ );
+ await BrowserTestUtils.waitForMutationCondition(
+ unsplitTabItem,
+ { attributes: true },
+ async () => {
+ return unsplitTabItem.hidden;
+ },
+ "unsplitTabItem is hidden"
+ );
+
+ moveTabToNewSplitViewItem.click();
+ }
+ );
+
+ let splitview = tab1.splitview;
+ [tab1, tab3].forEach((t, idx) => {
+ Assert.equal(t.splitview, splitview, `tabs[${idx}] is in split view`);
+ });
+ Assert.strictEqual(
+ Array.from(tabContainer.children).indexOf(splitview),
+ tab3Index - 1,
+ "Non-concecutive tabs have been added to split view and moved to active tab location"
+ );
+
+ splitview.unsplitTabs();
+
+ // Test adding consecutive tabs to a new split view
+
+ EventUtils.synthesizeMouseAtCenter(tab1, {});
+
+ [tab1, tab2].forEach(t => {
+ EventUtils.synthesizeMouseAtCenter(
+ t,
+ { ctrlKey: true, metaKey: true },
+ window
+ );
+ });
+
+ tabToClick = tab2;
+ await withTabMenu(
+ tabToClick,
+ async (moveTabToNewSplitViewItem, unsplitTabItem) => {
+ await BrowserTestUtils.waitForMutationCondition(
+ moveTabToNewSplitViewItem,
+ { attributes: true },
+ async () => {
+ return (
+ !moveTabToNewSplitViewItem.hidden &&
+ !moveTabToNewSplitViewItem.disabled
+ );
+ },
+ "moveTabToNewSplitViewItem is visible and not disabled"
+ );
+ await BrowserTestUtils.waitForMutationCondition(
+ unsplitTabItem,
+ { attributes: true },
+ async () => {
+ return unsplitTabItem.hidden;
+ },
+ "unsplitTabItem is hidden"
+ );
+
+ moveTabToNewSplitViewItem.click();
+ }
+ );
+
+ 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`);
+ });
+
+ // Test unsplitting tabs using context menu
+
+ await withTabMenu(
+ tabToClick,
+ async (moveTabToNewSplitViewItem, unsplitTabItem) => {
+ await BrowserTestUtils.waitForMutationCondition(
+ moveTabToNewSplitViewItem,
+ { attributes: true },
+ async () => {
+ return moveTabToNewSplitViewItem.hidden;
+ },
+ "moveTabToNewSplitViewItem is hidden"
+ );
+ await BrowserTestUtils.waitForMutationCondition(
+ unsplitTabItem,
+ { attributes: true },
+ async () => {
+ return !unsplitTabItem.hidden;
+ },
+ "unsplitTabItem is visible"
+ );
+
+ unsplitTabItem.click();
+ }
+ );
+
+ // Test adding split view with one tab and new tab
+
+ tabToClick = tab1;
+ EventUtils.synthesizeMouseAtCenter(tab1, {});
+
+ await withTabMenu(
+ tabToClick,
+ async (moveTabToNewSplitViewItem, unsplitTabItem) => {
+ await BrowserTestUtils.waitForMutationCondition(
+ moveTabToNewSplitViewItem,
+ { attributes: true },
+ async () => {
+ return (
+ !moveTabToNewSplitViewItem.hidden &&
+ !moveTabToNewSplitViewItem.disabled
+ );
+ },
+ "moveTabToNewSplitViewItem is visible and not disabled"
+ );
+ await BrowserTestUtils.waitForMutationCondition(
+ unsplitTabItem,
+ { attributes: true },
+ async () => {
+ return unsplitTabItem.hidden;
+ },
+ "unsplitTabItem is hidden"
+ );
+
+ moveTabToNewSplitViewItem.click();
+ }
+ );
+
+ splitview = tab1.splitview;
+
+ Assert.equal(tab1.splitview, splitview, `tab1 is in split view`);
+ Assert.equal(
+ splitview.tabs[1],
+ gBrowser.selectedTab,
+ "New tab is active in split view"
+ );
+ Assert.ok(!tab2.splitview, "tab2 is not in split view");
+ Assert.ok(!tab3.splitview, "tab3 is not in split view");
+
+ 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
@@ -372,4 +372,18 @@ tab-group-context-open-saved-group-in-new-window =
# Displayed within the tooltip on tabs inside of a tab split view
tabbrowser-tab-label-tab-split-view = Split view
+# Open a new tab next to the current tab and display their contents side by side
+tab-context-add-split-view =
+ .label = Add Split View
+ .accesskey = t
+# Display the two selected tabs' contents side by side
+tab-context-open-in-split-view =
+ .label = Open in Split View
+ .accesskey = t
+# Separate the two split view tabs and display the tabs and their contents as normal
+tab-context-separate-split-view =
+ .label = Separate Split View
+ .accesskey = t
+tab-context-badge-new = New
+
##
diff --git a/browser/themes/shared/tabbrowser/tabs.css b/browser/themes/shared/tabbrowser/tabs.css
@@ -1045,6 +1045,37 @@
/* Split View */
+tab-group > tab-split-view-wrapper {
+ #tabbrowser-tabs[orient="vertical"] & {
+ margin-inline-start: var(--space-xxsmall);
+ margin-block: calc(var(--space-xsmall) * -1);
+
+ .tabbrowser-tab:last-child {
+ .tab-group-line {
+ display: none;
+ }
+ }
+ }
+
+ @container vertical-tabs-container (max-width: 210px) {
+ #tabbrowser-tabs[orient="vertical"] & {
+ margin-inline-start: 0;
+
+ .tabbrowser-tab:last-child {
+ .tab-group-line {
+ display: flex;
+ }
+ }
+ }
+ }
+
+ #tabbrowser-tabs[orient="horizontal"] & > .tab-split-view-container {
+ margin-inline: 0;
+ padding-block-end: 0;
+ padding-inline: 0;
+ }
+}
+
#tabbrowser-tabs .tab-split-view-container {
display: grid;
grid-template-columns: 50% 50%;
@@ -1233,7 +1264,8 @@ tab-group {
}
#tabbrowser-tabs[orient="vertical"] &[movingtabgroup][collapsed] > .tabbrowser-tab[visuallyselected],
- #tabbrowser-tabs[orient="vertical"] &[collapsed] > .tabbrowser-tab:not([visuallyselected]) {
+ #tabbrowser-tabs[orient="vertical"] &[collapsed] > .tabbrowser-tab:not([visuallyselected]),
+ #tabbrowser-tabs[orient="vertical"] &[collapsed] > tab-split-view-wrapper:not([hasactivetab]) {
display: none;
}