tor-browser

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

commit 7bcabe02b22c661c043a779cd5f9e96ab8c913b5
parent 823c273c4f913f9e3ee2a2f7277d69c552d73131
Author: Kelly Cochrane <kcochrane@mozilla.com>
Date:   Wed, 15 Oct 2025 01:22:55 +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:
Mbrowser/app/profile/firefox.js | 3+++
Mbrowser/base/content/main-popupset.inc.xhtml | 7+++++++
Mbrowser/base/content/main-popupset.js | 9+++++++++
Mbrowser/components/tabbrowser/content/tab.js | 5+----
Mbrowser/components/tabbrowser/content/tabbrowser.js | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mbrowser/components/tabbrowser/content/tabgroup.js | 71++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mbrowser/components/tabbrowser/test/browser/tabs/browser_tab_splitview.js | 347++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mbrowser/locales/en-US/browser/tabbrowser.ftl | 14++++++++++++++
Mbrowser/themes/shared/tabbrowser/tabs.css | 34+++++++++++++++++++++++++++++++++-
9 files changed, 541 insertions(+), 37 deletions(-)

diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js @@ -2636,6 +2636,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 @@ -3235,9 +3235,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(); @@ -7418,7 +7421,7 @@ } getTabPids(tab) { - if (!tab.linkedBrowser) { + if (!tab?.linkedBrowser) { return []; } @@ -9500,6 +9503,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 = @@ -9923,6 +9954,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,44 @@ 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(); + info("Hide popup"); + return contextMenuHidden; +}; + add_task(async function test_splitViewCreateAndAddTabs() { let tab1 = BrowserTestUtils.addTab(gBrowser, "about:blank"); let tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank"); @@ -239,3 +278,309 @@ 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 }, + () => + !moveTabToNewSplitViewItem.hidden && + moveTabToNewSplitViewItem.disabled, + "moveTabToNewSplitViewItem is visible and disabled" + ); + Assert.ok( + !moveTabToNewSplitViewItem.hidden && moveTabToNewSplitViewItem.disabled, + "moveTabToNewSplitViewItem is visible and disabled" + ); + await BrowserTestUtils.waitForMutationCondition( + unsplitTabItem, + { attributes: true }, + () => unsplitTabItem.hidden, + "unsplitTabItem is hidden" + ); + Assert.ok(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 }, + () => + !moveTabToNewSplitViewItem.hidden && + !moveTabToNewSplitViewItem.disabled, + "moveTabToNewSplitViewItem is visible and not disabled" + ); + Assert.ok( + !moveTabToNewSplitViewItem.hidden && + !moveTabToNewSplitViewItem.disabled, + "moveTabToNewSplitViewItem is visible and not disabled" + ); + await BrowserTestUtils.waitForMutationCondition( + unsplitTabItem, + { attributes: true }, + () => unsplitTabItem.hidden, + "unsplitTabItem is hidden" + ); + Assert.ok(unsplitTabItem.hidden, "unsplitTabItem is hidden"); + + info("Click menu option to add new split view"); + moveTabToNewSplitViewItem.click(); + } + ); + + await BrowserTestUtils.waitForMutationCondition( + tabContainer, + { children: true }, + () => { + return ( + Array.from(tabContainer.children).some( + tabChild => tabChild.tagName === "tab-split-view-wrapper" + ) && + tab1.splitview && + tab3.splitview + ); + }, + "Split view has been added" + ); + info("Split view has been added"); + + let splitview = tab1.splitview; + [tab1, tab3].forEach((t, idx) => { + Assert.equal(t.splitview, splitview, `tabs[${idx}] is in split view`); + }); + Assert.equal( + Array.from(tabContainer.children).indexOf(splitview), + tab3Index - 1, + "Non-consecutive tabs have been added to split view and moved to active tab location" + ); + + info("Unsplit split view"); + splitview.unsplitTabs(); + + await BrowserTestUtils.waitForMutationCondition( + tabContainer, + { children: true }, + () => { + return ( + !Array.from(tabContainer.children).some( + tabChild => tabChild.tagName === "tab-split-view-wrapper" + ) && + !tab1.splitview && + !tab3.splitview + ); + }, + "Split view has been removed" + ); + info("Split view has been removed"); + + // 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 }, + () => + !moveTabToNewSplitViewItem.hidden && + !moveTabToNewSplitViewItem.disabled, + "moveTabToNewSplitViewItem is visible and not disabled" + ); + Assert.ok( + !moveTabToNewSplitViewItem.hidden && + !moveTabToNewSplitViewItem.disabled, + "moveTabToNewSplitViewItem is visible and not disabled" + ); + await BrowserTestUtils.waitForMutationCondition( + unsplitTabItem, + { attributes: true }, + () => unsplitTabItem.hidden, + "unsplitTabItem is hidden" + ); + Assert.ok(unsplitTabItem.hidden, "unsplitTabItem is hidden"); + + info("Click menu option to add new split view"); + moveTabToNewSplitViewItem.click(); + } + ); + + 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"); + + 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 }, + () => moveTabToNewSplitViewItem.hidden, + "moveTabToNewSplitViewItem is hidden" + ); + Assert.ok( + moveTabToNewSplitViewItem.hidden, + "moveTabToNewSplitViewItem is hidden" + ); + await BrowserTestUtils.waitForMutationCondition( + unsplitTabItem, + { attributes: true }, + () => !unsplitTabItem.hidden, + "unsplitTabItem is visible" + ); + Assert.ok(!unsplitTabItem.hidden, "unsplitTabItem is visible"); + + info("Unsplit split view using menu option"); + unsplitTabItem.click(); + } + ); + + 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 removed" + ); + info("Split view has been removed"); + + // 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 }, + () => + !moveTabToNewSplitViewItem.hidden && + !moveTabToNewSplitViewItem.disabled, + "moveTabToNewSplitViewItem is visible and not disabled" + ); + Assert.ok( + !moveTabToNewSplitViewItem.hidden && + !moveTabToNewSplitViewItem.disabled, + "moveTabToNewSplitViewItem is visible and not disabled" + ); + await BrowserTestUtils.waitForMutationCondition( + unsplitTabItem, + { attributes: true }, + () => unsplitTabItem.hidden, + "unsplitTabItem is hidden" + ); + Assert.ok(unsplitTabItem.hidden, "unsplitTabItem is hidden"); + + info("Click menu option to add new split view"); + moveTabToNewSplitViewItem.click(); + } + ); + + await BrowserTestUtils.waitForMutationCondition( + tabContainer, + { children: true }, + () => { + return ( + Array.from(tabContainer.children).some( + tabChild => tabChild.tagName === "tab-split-view-wrapper" + ) && tab1.splitview + ); + }, + "Split view has been added" + ); + info("Split view has been added"); + + 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; }