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:
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