tor-browser

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

commit f5ade40d4fc4a0b43da6204531a5f1d7ea4f4fc1
parent 2cc73d21c40c8238f9d4866e5c2fc67c4b2c3608
Author: Niklas Baumgardner <nbaumgardner@mozilla.com>
Date:   Wed, 29 Oct 2025 17:01:22 +0000

Bug 1990551 - Send tabs to other profiles from context menu. r=profiles-reviewers,fluent-reviewers,bolsson,jhirsch

Differential Revision: https://phabricator.services.mozilla.com/D269856

Diffstat:
Mbrowser/base/content/browser-profiles.js | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/base/content/browser-sets.inc | 1+
Mbrowser/base/content/browser-sets.js | 1+
Mbrowser/base/content/main-popupset.inc.xhtml | 1+
Mbrowser/base/content/main-popupset.js | 3+++
Mbrowser/components/profiles/SelectableProfileService.sys.mjs | 14+++++++++-----
Mbrowser/components/profiles/content/profile-selector.mjs | 2+-
Mbrowser/components/profiles/tests/browser/browser.toml | 2++
Abrowser/components/profiles/tests/browser/browser_moveTabToProfile.js | 220+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mbrowser/components/profiles/tests/unit/test_delete_last_profile.js | 4+---
Mbrowser/components/profiles/tests/unit/test_selectable_profile_launch.js | 4++--
Mbrowser/locales/en-US/browser/tabContextMenu.ftl | 4++++
12 files changed, 311 insertions(+), 11 deletions(-)

diff --git a/browser/base/content/browser-profiles.js b/browser/base/content/browser-profiles.js @@ -194,6 +194,16 @@ var gProfiles = { }); }, + async openTabsInProfile(aEvent, tabsToOpen) { + let profile = await SelectableProfileService.getProfile( + aEvent.target.getAttribute("profileid") + ); + SelectableProfileService.launchInstance( + profile, + tabsToOpen.map(tab => tab.linkedBrowser.currentURI.spec) + ); + }, + async handleCommand(aEvent) { switch (aEvent.target.id) { /* App menu button events */ @@ -247,6 +257,16 @@ var gProfiles = { this.launchProfile(aEvent.sourceEvent); break; } + case "Profiles:MoveTabsToProfile": { + let tabs; + if (TabContextMenu.contextTab.multiselected) { + tabs = gBrowser.selectedTabs; + } else { + tabs = [TabContextMenu.contextTab]; + } + this.openTabsInProfile(aEvent.sourceEvent, tabs); + break; + } } /* Subpanel profile events that may be triggered in FxA menu or app menu */ if (aEvent.target.classList.contains("profile-item")) { @@ -427,4 +447,50 @@ var gProfiles = { profilesList.appendChild(button); } }, + + async populateMoveTabMenu(menuPopup) { + if (!SelectableProfileService.initialized) { + return; + } + + const profiles = await SelectableProfileService.getAllProfiles(); + const currentProfile = SelectableProfileService.currentProfile; + + const separator = document.getElementById("moveTabSeparator"); + separator.hidden = profiles.length < 2; + + let existingItems = [ + ...menuPopup.querySelectorAll(":scope > menuitem[profileid]"), + ]; + + for (let profile of profiles) { + if (profile.id === currentProfile.id) { + continue; + } + + let menuitem = existingItems.shift(); + let isNewItem = !menuitem; + if (isNewItem) { + menuitem = document.createXULElement("menuitem"); + menuitem.setAttribute("tbattr", "tabbrowser-multiple-visible"); + menuitem.setAttribute("data-l10n-id", "move-to-new-profile"); + menuitem.setAttribute("command", "Profiles:MoveTabsToProfile"); + } + + menuitem.disabled = false; + menuitem.setAttribute("profileid", profile.id); + menuitem.setAttribute( + "data-l10n-args", + JSON.stringify({ profileName: profile.name }) + ); + + if (isNewItem) { + menuPopup.appendChild(menuitem); + } + } + // If there's any old item to remove, do so now. + for (let remaining of existingItems) { + remaining.remove(); + } + }, }; diff --git a/browser/base/content/browser-sets.inc b/browser/base/content/browser-sets.inc @@ -78,6 +78,7 @@ <command id="Profiles:CreateProfile" /> <command id="Profiles:ManageProfiles" /> <command id="Profiles:LaunchProfile" /> + <command id="Profiles:MoveTabsToProfile" /> <command id="Browser:NextTab" /> <command id="Browser:PrevTab" /> <command id="Browser:ShowAllTabs" /> diff --git a/browser/base/content/browser-sets.js b/browser/base/content/browser-sets.js @@ -203,6 +203,7 @@ document.addEventListener( case "Profiles:CreateProfile": case "Profiles:ManageProfiles": case "Profiles:LaunchProfile": + case "Profiles:MoveTabsToProfile": gProfiles.handleCommand(event); break; case "Tools:Search": diff --git a/browser/base/content/main-popupset.inc.xhtml b/browser/base/content/main-popupset.inc.xhtml @@ -75,6 +75,7 @@ tbattr="tabbrowser-multiple-visible"/> <menuitem id="context_openTabInWindow" data-lazy-l10n-id="move-to-new-window" tbattr="tabbrowser-multiple-visible"/> + <menuseparator id="moveTabSeparator" hidden="true"/> </menupopup> </menu> <menu id="context_sendTabToDevice" diff --git a/browser/base/content/main-popupset.js b/browser/base/content/main-popupset.js @@ -532,6 +532,9 @@ document.addEventListener( case "tabContextMenu": TabContextMenu.addNewBadge(); break; + case "moveTabOptionsMenu": + gProfiles.populateMoveTabMenu(event.target); + break; } }); diff --git a/browser/components/profiles/SelectableProfileService.sys.mjs b/browser/components/profiles/SelectableProfileService.sys.mjs @@ -619,13 +619,17 @@ class SelectableProfileServiceClass extends EventEmitter { * Launch a new Firefox instance using the given selectable profile. * * @param {SelectableProfile} aProfile The profile to launch - * @param {string} aUrl A url to open in launched profile + * @param {Array<string>} aUrls An array of urls to open in launched profile */ - launchInstance(aProfile, aUrl) { + launchInstance(aProfile, aUrls) { let args = []; - if (aUrl) { - args.push("-url", aUrl); + if (aUrls?.length) { + // See https://wiki.mozilla.org/Firefox/CommandLineOptions#-url_URL + // Use '-new-tab' instead of '-url' because when opening multiple URLs, + // Firefox always opens them as tabs in a new window and we want to + // attempt opening these tabs in an existing window. + args.push(...aUrls.flatMap(url => ["-new-tab", url])); } else { args.push(`--${COMMAND_LINE_ACTIVATE}`); } @@ -1440,7 +1444,7 @@ class SelectableProfileServiceClass extends EventEmitter { let profile = await this.#createProfile(); if (launchProfile) { - this.launchInstance(profile, "about:newprofile"); + this.launchInstance(profile, ["about:newprofile"]); } return profile; } diff --git a/browser/components/profiles/content/profile-selector.mjs b/browser/components/profiles/content/profile-selector.mjs @@ -126,7 +126,7 @@ export class ProfileSelector extends MozLitElement { await this.setLaunchArguments(profile, url ? ["-url", url] : []); await this.selectableProfileService.uninit(); } else { - this.selectableProfileService.launchInstance(profile, url); + this.selectableProfileService.launchInstance(profile, [url]); } window.close(); diff --git a/browser/components/profiles/tests/browser/browser.toml b/browser/components/profiles/tests/browser/browser.toml @@ -50,6 +50,8 @@ skip-if = [ ["browser_menubar_profiles.js"] head = "../unit/head.js head.js" +["browser_moveTabToProfile.js"] + ["browser_notify_changes.js"] run-if = ["os != 'linux'"] # Linux clients cannot remote themselves. skip-if = ["os == 'mac' && os_version == '15.30' && arch == 'aarch64' && opt && !socketprocess_networking"] # Bug 1929273 diff --git a/browser/components/profiles/tests/browser/browser_moveTabToProfile.js b/browser/components/profiles/tests/browser/browser_moveTabToProfile.js @@ -0,0 +1,220 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); +const { ObjectUtils } = ChromeUtils.importESModule( + "resource://gre/modules/ObjectUtils.sys.mjs" +); + +const EXAMPLE_URL = "https://example.com/"; + +async function addTab(url = EXAMPLE_URL) { + const tab = BrowserTestUtils.addTab(gBrowser, url, { + skipAnimation: true, + }); + const browser = gBrowser.getBrowserForTab(tab); + await BrowserTestUtils.browserLoaded(browser); + return tab; +} + +async function openContextMenu(tab) { + let contextMenu = document.getElementById("tabContextMenu"); + let openTabContextMenuPromise = BrowserTestUtils.waitForPopupEvent( + contextMenu, + "shown" + ); + + await sendAndWaitForMouseEvent(tab, { type: "contextmenu" }); + await openTabContextMenuPromise; + return contextMenu; +} + +async function openMoveTabOptionsMenuPopup(contextMenu) { + let moveTabMenuItem = contextMenu.querySelector("#context_moveTabOptions"); + let subMenu = contextMenu.querySelector("#moveTabOptionsMenu"); + let popupShown = BrowserTestUtils.waitForEvent(subMenu, "popupshown"); + + if (AppConstants.platform === "macosx") { + moveTabMenuItem.openMenu(true); + } else { + await sendAndWaitForMouseEvent(moveTabMenuItem); + } + + await popupShown; + + const separator = subMenu.querySelector("#moveTabSeparator"); + await TestUtils.waitForCondition( + () => + BrowserTestUtils.isVisible(separator) && + BrowserTestUtils.isVisible(separator.nextElementSibling) + ); + + return subMenu; +} + +async function clickMoveToProfileMenuItem(subMenu) { + let profileMenuItem = subMenu.querySelector(":scope > menuitem[profileid]"); + if (AppConstants.platform === "macosx") { + subMenu.activateItem(profileMenuItem); + } else { + await sendAndWaitForMouseEvent(profileMenuItem); + } +} + +async function sendAndWaitForMouseEvent(target, options = {}) { + let promise = BrowserTestUtils.waitForEvent(target, options.type ?? "click"); + EventUtils.synthesizeMouseAtCenter(target, options); + return promise; +} + +const execProcess = sinon.fake(); +const sendCommandLine = sinon.fake.throws(Cr.NS_ERROR_NOT_AVAILABLE); +sinon.replace( + SelectableProfileService, + "sendCommandLine", + (path, args, raise) => sendCommandLine(path, [...args], raise) +); +sinon.replace(SelectableProfileService, "execProcess", execProcess); + +registerCleanupFunction(() => { + sinon.restore(); +}); + +let lastCommandLineCallCount = 1; +async function assertCommandLineExists(expected) { + await TestUtils.waitForCondition( + () => sendCommandLine.callCount > lastCommandLineCallCount, + "Waiting for notify task to complete" + ); + + let allCommandLineCalls = sendCommandLine.getCalls(); + + lastCommandLineCallCount++; + + let expectedCount = allCommandLineCalls.reduce((count, call) => { + if (ObjectUtils.deepEqual(call.args, expected)) { + return count + 1; + } + + return count; + }, 0); + + Assert.equal(expectedCount, 1, "Found expected args"); + Assert.deepEqual( + allCommandLineCalls.find(call => ObjectUtils.deepEqual(call.args, expected)) + .args, + expected, + "Expected sendCommandLine arguments to open tab in profile" + ); +} + +add_task(async function test_moveSelectedTab() { + await initGroupDatabase(); + + const allProfiles = await SelectableProfileService.getAllProfiles(); + let otherProfile; + if (allProfiles.length < 2) { + otherProfile = await SelectableProfileService.createNewProfile(false); + } else { + otherProfile = allProfiles.find( + p => p.id !== SelectableProfileService.currentProfile.id + ); + } + + let tab2 = await addTab(EXAMPLE_URL + "2"); + + gBrowser.selectedTab = tab2; + + let contextMenu = await openContextMenu(tab2); + let subMenu = await openMoveTabOptionsMenuPopup(contextMenu); + await clickMoveToProfileMenuItem(subMenu); + + let expectedArgs = ["-new-tab", EXAMPLE_URL + "2"]; + + await assertCommandLineExists([otherProfile.path, expectedArgs, true]); + + gBrowser.removeTab(tab2); +}); + +add_task(async function test_moveNonSelectedTab() { + await initGroupDatabase(); + + const allProfiles = await SelectableProfileService.getAllProfiles(); + let otherProfile; + if (allProfiles.length < 2) { + otherProfile = await SelectableProfileService.createNewProfile(false); + } else { + otherProfile = allProfiles.find( + p => p.id !== SelectableProfileService.currentProfile.id + ); + } + + let tab2 = await addTab(EXAMPLE_URL + "2"); + let tab3 = await addTab(EXAMPLE_URL + "3"); + + gBrowser.selectedTab = tab2; + + let contextMenu = await openContextMenu(tab3); + let subMenu = await openMoveTabOptionsMenuPopup(contextMenu); + await clickMoveToProfileMenuItem(subMenu); + + let expectedArgs = ["-new-tab", EXAMPLE_URL + "3"]; + + await assertCommandLineExists([otherProfile.path, expectedArgs, true]); + + gBrowser.removeTabs([tab2, tab3]); +}); + +add_task(async function test_moveMultipleSelectedTabs() { + await initGroupDatabase(); + + const allProfiles = await SelectableProfileService.getAllProfiles(); + let otherProfile; + if (allProfiles.length < 2) { + otherProfile = await SelectableProfileService.createNewProfile(false); + } else { + otherProfile = allProfiles.find( + p => p.id !== SelectableProfileService.currentProfile.id + ); + } + + let tab2 = await addTab(EXAMPLE_URL + "2"); + let tab3 = await addTab(EXAMPLE_URL + "3"); + let tab4 = await addTab(EXAMPLE_URL + "4"); + + gBrowser.selectedTab = tab2; + + await sendAndWaitForMouseEvent(tab2); + if (AppConstants.platform === "macosx") { + await sendAndWaitForMouseEvent(tab3, { metaKey: true }); + await sendAndWaitForMouseEvent(tab4, { metaKey: true }); + } else { + await sendAndWaitForMouseEvent(tab3, { ctrlKey: true }); + await sendAndWaitForMouseEvent(tab4, { ctrlKey: true }); + } + + Assert.ok(tab2.multiselected, "Tab2 is multiselected"); + Assert.ok(tab3.multiselected, "Tab3 is multiselected"); + Assert.ok(tab4.multiselected, "Tab4 is multiselected"); + + let contextMenu = await openContextMenu(tab4); + let subMenu = await openMoveTabOptionsMenuPopup(contextMenu); + await clickMoveToProfileMenuItem(subMenu); + + let expectedArgs = [ + "-new-tab", + EXAMPLE_URL + "2", + "-new-tab", + EXAMPLE_URL + "3", + "-new-tab", + EXAMPLE_URL + "4", + ]; + + await assertCommandLineExists([otherProfile.path, expectedArgs, true]); + + gBrowser.removeTabs([tab2, tab3, tab4]); +}); diff --git a/browser/components/profiles/tests/unit/test_delete_last_profile.js b/browser/components/profiles/tests/unit/test_delete_last_profile.js @@ -15,8 +15,6 @@ const execProcess = sinon.fake(); const sendCommandLine = sinon.fake.throws(Cr.NS_ERROR_NOT_AVAILABLE); add_setup(async () => { - await initSelectableProfileService(); - sinon.replace( getSelectableProfileService(), "sendCommandLine", @@ -53,7 +51,7 @@ add_task(async function test_delete_last_profile() { profile = profiles[0]; - let expected = ["-url", "about:newprofile"]; + let expected = ["-new-tab", "about:newprofile"]; await TestUtils.waitForCondition( () => sendCommandLine.callCount > 1, diff --git a/browser/components/profiles/tests/unit/test_selectable_profile_launch.js b/browser/components/profiles/tests/unit/test_selectable_profile_launch.js @@ -59,9 +59,9 @@ add_task(async function test_launcher() { sendCommandLine.resetHistory(); execProcess.resetHistory(); - SelectableProfileService.launchInstance(profile, "about:profilemanager"); + SelectableProfileService.launchInstance(profile, ["about:profilemanager"]); - expected = ["-url", "about:profilemanager"]; + expected = ["-new-tab", "about:profilemanager"]; Assert.equal( sendCommandLine.callCount, diff --git a/browser/locales/en-US/browser/tabContextMenu.ftl b/browser/locales/en-US/browser/tabContextMenu.ftl @@ -93,6 +93,10 @@ move-to-end = move-to-new-window = .label = Move to New Window .accesskey = W +# Variables +# $profileName (string): The name of the profile to move tab to +move-to-new-profile = + .label = Move to { $profileName } tab-context-close-multiple-tabs = .label = Close Multiple Tabs .accesskey = M