commit 119527231c32d279fa3565faa8a94963cf95e05b
parent 6b53a2ec7336bf3350d55d4ce634810abd26ac38
Author: Jared Hirsch <ohai@6a68.net>
Date: Mon, 24 Nov 2025 23:17:34 +0000
Bug 1992201 - Add settings sub-pane for profiles, including copy feature r=profiles-reviewers,fluent-reviewers,desktop-theme-reviewers,bolsson,hjones,niklas
Note we hide the copy profile section until you have either created a
profile (app menu > profiles > new profile) or inited the database (by
clicking the 'manage profiles' button just above. Then you have to
refresh the page.
Differential Revision: https://phabricator.services.mozilla.com/D270043
Diffstat:
8 files changed, 399 insertions(+), 35 deletions(-)
diff --git a/browser/components/preferences/main.inc.xhtml b/browser/components/preferences/main.inc.xhtml
@@ -47,23 +47,7 @@
<!-- Profiles -->
<groupbox id="profilesGroup" data-category="paneGeneral" hidden="true">
- <label><html:h2 data-l10n-id="preferences-profiles-header"/></label>
-
- <hbox id="profiles" flex="1">
- <description flex="1" class="description-deemphasized" control="manage-profiles">
- <html:span data-l10n-id="preferences-manage-profiles-description"></html:span>
- <html:a
- is="moz-support-link"
- id="profile-management-learn-more"
- support-page="profile-management"
- data-l10n-id="preferences-manage-profiles-learn-more"
- />
- </description>
- <button id="manage-profiles"
- is="highlightable-button"
- class="accessory-button"
- data-l10n-id="preferences-manage-profiles-button"/>
- </hbox>
+ <html:setting-group groupid="profiles" data-category="paneGeneral"></html:setting-group>
</groupbox>
<!-- Tab preferences -->
diff --git a/browser/components/preferences/main.js b/browser/components/preferences/main.js
@@ -634,6 +634,122 @@ Preferences.addSetting({
onUserClick: () => gMainPane.showConnections(),
});
+Preferences.addSetting({
+ id: "profilesPane",
+ onUserClick(e) {
+ e.preventDefault();
+ gotoPref("paneProfiles");
+ },
+});
+Preferences.addSetting({
+ id: "profilesSettings",
+ visible() {
+ return SelectableProfileService.isEnabled;
+ },
+ onUserClick: e => {
+ e.preventDefault();
+ gotoPref("profiles");
+ },
+});
+Preferences.addSetting({
+ id: "manageProfiles",
+ onUserClick: e => {
+ e.preventDefault();
+ // Using the existing function for now, since privacy.js also calls it
+ gMainPane.manageProfiles();
+ },
+});
+Preferences.addSetting({
+ id: "copyProfile",
+ deps: ["copyProfileSelect"],
+ disabled: ({ copyProfileSelect }) => !copyProfileSelect.value,
+ onUserClick: (e, { copyProfileSelect }) => {
+ e.preventDefault();
+ SelectableProfileService.getProfile(copyProfileSelect.value).then(
+ profile => {
+ profile?.copyProfile();
+ copyProfileSelect.config.set("");
+ }
+ );
+ },
+});
+Preferences.addSetting({
+ id: "copyProfileBox",
+ visible: () => SelectableProfileService.initialized,
+});
+Preferences.addSetting({
+ id: "copyProfileError",
+ _hasError: false,
+ setup(emitChange) {
+ this.emitChange = emitChange;
+ },
+ visible() {
+ return this._hasError;
+ },
+ setError(value) {
+ this._hasError = !!value;
+ this.emitChange();
+ },
+});
+Preferences.addSetting(
+ class ProfileList extends Preferences.AsyncSetting {
+ static id = "profileList";
+ static PROFILE_UPDATED_OBS = "sps-profiles-updated";
+ setup() {
+ Services.obs.addObserver(
+ this.emitChange,
+ ProfileList.PROFILE_UPDATED_OBS
+ );
+ return () => {
+ Services.obs.removeObserver(
+ this.emitChange,
+ ProfileList.PROFILE_UPDATED_OBS
+ );
+ };
+ }
+
+ async get() {
+ let profiles = await SelectableProfileService.getAllProfiles();
+ return profiles;
+ }
+ }
+);
+Preferences.addSetting({
+ id: "copyProfileSelect",
+ deps: ["profileList"],
+ _selectedProfile: null,
+ setup(emitChange) {
+ this.emitChange = emitChange;
+ document.l10n
+ .formatValue("preferences-copy-profile-select")
+ .then(result => (this.placeholderString = result));
+ },
+ get() {
+ return this._selectedProfile;
+ },
+ set(inputVal) {
+ this._selectedProfile = inputVal;
+ this.emitChange();
+ },
+ getControlConfig(config, { profileList }) {
+ config.options = profileList.value.map(profile => {
+ return { controlAttrs: { label: profile.name }, value: profile.id };
+ });
+
+ // Put the placeholder at the front of the list.
+ config.options.unshift({
+ controlAttrs: { label: this.placeholderString },
+ value: "",
+ });
+
+ return config;
+ },
+});
+Preferences.addSetting({
+ id: "copyProfileHeader",
+ visible: () => SelectableProfileService.initialized,
+});
+
// Downloads
/*
* Preferences:
@@ -1512,6 +1628,62 @@ SettingGroupManager.registerGroups({
},
],
},
+ profilePane: {
+ headingLevel: 2,
+ id: "browserProfilesGroupPane",
+ l10nId: "preferences-profiles-subpane-description",
+ supportPage: "profile-management",
+ items: [
+ {
+ id: "manageProfiles",
+ control: "moz-box-button",
+ l10nId: "preferences-manage-profiles-button",
+ },
+ {
+ id: "copyProfileHeader",
+ l10nId: "preferences-copy-profile-header",
+ headingLevel: 2,
+ supportPage: "profile-management",
+ control: "moz-fieldset",
+ items: [
+ {
+ id: "copyProfileBox",
+ l10nId: "preferences-profile-to-copy",
+ control: "moz-box-item",
+ items: [
+ {
+ id: "copyProfileSelect",
+ control: "moz-select",
+ slot: "actions",
+ },
+ {
+ id: "copyProfile",
+ l10nId: "preferences-copy-profile-button",
+ control: "moz-button",
+ slot: "actions",
+ controlAttrs: {
+ type: "primary",
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ profiles: {
+ id: "profilesGroup",
+ l10nId: "preferences-profiles-section-header",
+ headingLevel: 2,
+ supportPage: "profile-management",
+ items: [
+ {
+ id: "profilesSettings",
+ control: "moz-box-button",
+ l10nId: "preferences-profiles-settings-button",
+ },
+ ],
+ },
startup: {
items: [
{
@@ -2854,6 +3026,8 @@ var gMainPane = {
initSettingGroup("startup");
initSettingGroup("networkProxy");
initSettingGroup("tabs");
+ initSettingGroup("profiles");
+ initSettingGroup("profilePane");
setEventListener("manageBrowserLanguagesButton", "command", function () {
gMainPane.showBrowserLanguagesSubDialog({ search: false });
@@ -2922,16 +3096,6 @@ var gMainPane = {
document.getElementById("dataMigrationGroup").remove();
}
- if (!SelectableProfileService.isEnabled) {
- // Don't want to rely on .hidden for the toplevel groupbox because
- // of the pane hiding/showing code potentially interfering:
- document
- .getElementById("profilesGroup")
- .setAttribute("style", "display: none !important");
- } else {
- setEventListener("manage-profiles", "command", gMainPane.manageProfiles);
- }
-
// Initializes the fonts dropdowns displayed in this pane.
this._rebuildFonts();
diff --git a/browser/components/preferences/preferences.js b/browser/components/preferences/preferences.js
@@ -281,6 +281,11 @@ const CONFIG_PANES = Object.freeze({
l10nId: "autofill-payment-methods-manage-payments-title",
groupIds: ["managePayments"],
},
+ paneProfiles: {
+ parent: "general",
+ l10nId: "preferences-profiles-group-header",
+ groupIds: ["profilePane"],
+ },
});
var gLastCategory = { category: undefined, subcategory: undefined };
diff --git a/browser/components/preferences/preferences.xhtml b/browser/components/preferences/preferences.xhtml
@@ -202,6 +202,10 @@
<richlistitem
class="category hidden-category"
+ value="paneProfiles"/>
+
+ <richlistitem
+ class="category hidden-category"
value="paneTranslations"/>
</richlistbox>
diff --git a/browser/components/preferences/widgets/setting-control/setting-control.mjs b/browser/components/preferences/widgets/setting-control/setting-control.mjs
@@ -367,7 +367,9 @@ export class SettingControl extends SettingElement {
.config=${item.config}
.setting=${item.setting}
.getSetting=${this.getSetting}
- slot=${ifDefined(ITEM_SLOT_BY_PARENT.get(control))}
+ slot=${ifDefined(
+ item.config.slot || ITEM_SLOT_BY_PARENT.get(control)
+ )}
></setting-control>`
);
}
diff --git a/browser/components/profiles/tests/browser/browser_preferences.js b/browser/components/profiles/tests/browser/browser_preferences.js
@@ -7,6 +7,7 @@ const lazy = {};
ChromeUtils.defineESModuleGetters(lazy, {
ClientID: "resource://gre/modules/ClientID.sys.mjs",
TelemetryUtils: "resource://gre/modules/TelemetryUtils.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
});
// Note: copied from preferences head.js. We can remove this when we migrate
@@ -92,6 +93,17 @@ function promiseLoadSubDialog(aURL) {
});
}
+// Note: copied from preferences head.js. We can remove this when we migrate
+// this test into that component.
+async function waitForPaneChange(paneId) {
+ let doc = gBrowser.selectedBrowser.contentDocument;
+ let event = await BrowserTestUtils.waitForEvent(doc, "paneshown");
+ let expectId = paneId.startsWith("pane")
+ ? paneId
+ : `pane${paneId[0].toUpperCase()}${paneId.substring(1)}`;
+ is(event.detail.category, expectId, "Loaded the correct pane");
+}
+
add_task(async function testHiddenWhenDisabled() {
await SpecialPowers.pushPrefEnv({
set: [["browser.profiles.enabled", false]],
@@ -116,6 +128,7 @@ add_task(async function testEnabled() {
leaveOpen: true,
});
let doc = gBrowser.contentDocument;
+ let win = doc.ownerGlobal;
// Verify the profiles section is shown when enabled.
let profilesCategory = doc.getElementById("profilesGroup");
@@ -124,22 +137,166 @@ add_task(async function testEnabled() {
ok(BrowserTestUtils.isVisible(profilesCategory), "The category is visible");
// Verify the Learn More link exists and points to the right place.
- let learnMore = doc.getElementById("profile-management-learn-more");
+ let profilesSettingGroup = doc.querySelector(
+ "setting-group[groupid='profiles']"
+ ).firstElementChild;
+ let learnMore = profilesSettingGroup.shadowRoot.querySelector(
+ "a[is='moz-support-link']"
+ );
Assert.equal(
"http://127.0.0.1:8888/support-dummy/profile-management",
learnMore.href,
"Learn More link should have expected URL"
);
- // Verify that clicking the button shows the manage screen in a subdialog.
+ // Verify that clicking the button opens the expected subpane.
+ let profilesSubPane = doc.querySelector(
+ "setting-pane[data-category='paneProfiles']"
+ );
+ ok(
+ !BrowserTestUtils.isVisible(profilesSubPane),
+ "Profiles subpane should be hidden"
+ );
+ let paneLoaded = waitForPaneChange("profiles");
+ let subPaneButton = profilesSettingGroup.querySelector("#profilesSettings");
+ subPaneButton.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(subPaneButton, {}, win);
+ await paneLoaded;
+ ok(
+ BrowserTestUtils.isVisible(profilesSubPane),
+ "Profiles subpane should be visible"
+ );
+ await profilesSubPane.updateComplete;
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_task(async function subpaneContentsWithOneProfile() {
+ await openPreferencesViaOpenPreferencesAPI("paneGeneral", {
+ leaveOpen: true,
+ });
+ let doc = gBrowser.contentDocument;
+ let win = doc.ownerGlobal;
+
+ let paneLoaded = waitForPaneChange("profiles");
+ win.gotoPref("paneProfiles");
+ await paneLoaded;
+
+ let profilesSubPane = doc.querySelector(
+ "setting-pane[data-category='paneProfiles']"
+ );
+ await profilesSubPane.updateComplete;
+
+ Assert.equal(
+ "preferences-profiles-group-header",
+ profilesSubPane
+ .querySelector("moz-page-header")
+ .getAttribute("data-l10n-id"),
+ "Subpane should have expected heading l10nId"
+ );
+
+ let manageProfilesButton = profilesSubPane.querySelector("#manageProfiles");
+ ok(
+ BrowserTestUtils.isVisible(manageProfilesButton),
+ "Manage profiles button should be visible"
+ );
+
+ let copyProfilesSection = profilesSubPane.querySelector("#copyProfileHeader");
+ ok(
+ !BrowserTestUtils.isVisible(copyProfilesSection),
+ "Until we create a second profile, the copy section should be hidden"
+ );
+
+ // Verify the manage profiles button opens the correct subdialog.
+ manageProfilesButton.scrollIntoView();
let promiseSubDialogLoaded = promiseLoadSubDialog("about:profilemanager");
- let profilesButton = doc.getElementById("manage-profiles");
- profilesButton.click();
+ EventUtils.synthesizeMouseAtCenter(manageProfilesButton, {}, win);
await promiseSubDialogLoaded;
BrowserTestUtils.removeTab(gBrowser.selectedTab);
});
+add_task(async function copyProfile() {
+ // Add an additional profile, then load the subpane, and the copy section should be visible.
+ await initGroupDatabase();
+ await SelectableProfileService.createNewProfile(false);
+
+ await openPreferencesViaOpenPreferencesAPI("paneGeneral", {
+ leaveOpen: true,
+ });
+ let doc = gBrowser.contentDocument;
+ let win = doc.ownerGlobal;
+
+ let paneLoaded = waitForPaneChange("profiles");
+ win.gotoPref("paneProfiles");
+ await paneLoaded;
+ let profilesSubPane = doc.querySelector(
+ "setting-pane[data-category='paneProfiles']"
+ );
+ await profilesSubPane.updateComplete;
+
+ let manageProfilesButton = profilesSubPane.querySelector("#manageProfiles");
+ ok(
+ BrowserTestUtils.isVisible(manageProfilesButton),
+ "Manage profiles button should be visible"
+ );
+
+ let copyProfilesSection = profilesSubPane.querySelector("#copyProfileHeader");
+ ok(
+ BrowserTestUtils.isVisible(copyProfilesSection),
+ "Copy profile section should be visible"
+ );
+
+ let profilesCopyButton = copyProfilesSection.querySelector("#copyProfile");
+ let profilesSelect = copyProfilesSection.querySelector("#copyProfileSelect");
+ ok(
+ profilesCopyButton.disabled,
+ "Initially the copy button should be disabled"
+ );
+ ok(!profilesSelect.value, "Initially the select value should be unset");
+ Assert.equal(
+ profilesSelect.options.length,
+ 3,
+ "Both profiles and the placeholder should be in the options list"
+ );
+ Assert.equal(
+ profilesSelect.options[1].label,
+ "Original profile",
+ "The first profile should be listed"
+ );
+ Assert.equal(
+ profilesSelect.options[2].label,
+ "Profile 1",
+ "The second profile should be listed"
+ );
+
+ profilesSelect.value = "1";
+ profilesSelect.dispatchEvent(new win.Event("change", { bubbles: true }));
+ await profilesCopyButton.updateComplete;
+ ok(
+ !profilesCopyButton.disabled,
+ "When a profile is selected, the copy button should be enabled"
+ );
+
+ let copyCalled = false;
+ let mockGetProfile = lazy.sinon
+ .stub(SelectableProfileService, "getProfile")
+ .resolves({
+ copyProfile: () => (copyCalled = true),
+ });
+ profilesCopyButton.scrollIntoView();
+ EventUtils.synthesizeMouseAtCenter(profilesCopyButton, {}, win);
+ await profilesSelect.updateComplete;
+ ok(copyCalled, "The profile copy method should have been called");
+ ok(
+ !profilesSelect.value,
+ "After copy, select value should be reset to empty value"
+ );
+
+ mockGetProfile.restore();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
// Tests for the small addition to the privacy section
add_task(async function testPrivacyInfoEnabled() {
ok(SelectableProfileService.isEnabled, "service should be enabled");
diff --git a/browser/locales/en-US/browser/preferences/preferences.ftl b/browser/locales/en-US/browser/preferences/preferences.ftl
@@ -177,11 +177,31 @@ preferences-data-migration-button =
.label = Import Data
.accesskey = m
-preferences-profiles-header = Profiles
-preferences-manage-profiles-description = Each profile has separate browsing data and settings, including history, passwords, and more.
-preferences-manage-profiles-learn-more = Learn more
+preferences-profiles-group-header =
+ .heading = Profiles
+preferences-profiles-subpane-description =
+ .description = Each profile has separate browsing data and settings, including history, passwords, and more.
+preferences-profiles-section-header =
+ .label = Profiles
+ .description = Each profile has separate browsing data and settings, including history, passwords, and more.
preferences-manage-profiles-button =
.label = Manage Profiles
+preferences-profiles-settings-button =
+ .label = Settings
+# This string labels the entire copy profile section in the profiles sub-pane.
+preferences-copy-profile-header =
+ .label = Copy an existing profile
+ .description = The new profile will copy your settings, add-ons, history, and saved data like bookmarks and passwords — but not your account or sync info.
+# This string sits next to the copy controls, both the copy-profile-select
+# drop-down and the copy-profile-button, so that the user understands they
+# need to first pick a profile to copy, and then click the copy button.
+preferences-profile-to-copy =
+ .label = Profile to copy
+# This string is a placeholder that will be shown in a drop-down list of
+# profiles. The user will select a profile, then click the copy button
+# to make a copy of that profile.
+preferences-copy-profile-select = Select profile
+preferences-copy-profile-button = Copy
tabs-group-header2 =
.label = Tabs
diff --git a/python/l10n/fluent_migrations/bug_1992201_preferences_profiles.py b/python/l10n/fluent_migrations/bug_1992201_preferences_profiles.py
@@ -0,0 +1,28 @@
+# Any copyright is dedicated to the Public Domain.
+# http://creativecommons.org/publicdomain/zero/1.0/
+
+from fluent.migrate import COPY_PATTERN
+from fluent.migrate.helpers import transforms_from
+
+
+def migrate(ctx):
+ """Bug 1992201 - Add settings sub-pane for profiles, part {index}."""
+ source = "browser/browser/preferences/preferences.ftl"
+ target = source
+
+ ctx.add_transforms(
+ target,
+ source,
+ transforms_from(
+ """
+preferences-profiles-group-header =
+ .heading = {COPY_PATTERN(from_path, "preferences-profiles-header")}
+preferences-profiles-subpane-description =
+ .description = {COPY_PATTERN(from_path, "preferences-manage-profiles-description")}
+preferences-profiles-section-header =
+ .label = {COPY_PATTERN(from_path, "preferences-profiles-header")}
+ .description = {COPY_PATTERN(from_path, "preferences-manage-profiles-description")}
+""",
+ from_path=source,
+ ),
+ )