tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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:
Mbrowser/base/content/main-popupset.inc.xhtml | 10++++++++--
Mbrowser/base/content/main-popupset.js | 6++++++
Mbrowser/components/tabbrowser/content/tabbrowser.js | 178++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mbrowser/components/tabbrowser/content/tabgroup.js | 34+++++++++++++++++++++-------------
Mbrowser/components/tabbrowser/content/tabsplitview.js | 9+++++++++
Mbrowser/components/tabbrowser/test/browser/tabs/browser_tab_groups.js | 21++++++++++++++++++++-
Mbrowser/components/tabbrowser/test/browser/tabs/browser_tab_groups_tab_interactions_telemetry.js | 4+++-
Mbrowser/components/tabbrowser/test/browser/tabs/browser_tab_preview.js | 2+-
Mbrowser/components/tabbrowser/test/browser/tabs/browser_tab_splitview_contextmenu.js | 133+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mbrowser/locales/en-US/browser/tabbrowser.ftl | 15+++++++++++++++
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 ##