Bug 1944627 - update sidebar button checked state for non-revamped sidebar cases...
[gecko.git] / browser / components / preferences / search.js
blobd20e974dded3bfcd4996c488e75c6bcd5ff07463
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 /* import-globals-from extensionControlled.js */
6 /* import-globals-from preferences.js */
8 const lazy = {};
10 ChromeUtils.defineESModuleGetters(lazy, {
11 SearchUIUtils: "resource:///modules/SearchUIUtils.sys.mjs",
12 SearchUtils: "resource://gre/modules/SearchUtils.sys.mjs",
13 UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
14 CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
15 });
17 const PREF_URLBAR_QUICKSUGGEST_BLOCKLIST =
18 "browser.urlbar.quicksuggest.blockedDigests";
19 const PREF_URLBAR_WEATHER_USER_ENABLED = "browser.urlbar.suggest.weather";
21 Preferences.addAll([
22 { id: "browser.search.suggest.enabled", type: "bool" },
23 { id: "browser.urlbar.suggest.searches", type: "bool" },
24 { id: "browser.search.suggest.enabled.private", type: "bool" },
25 { id: "browser.urlbar.showSearchSuggestionsFirst", type: "bool" },
26 { id: "browser.urlbar.showSearchTerms.enabled", type: "bool" },
27 { id: "browser.search.separatePrivateDefault", type: "bool" },
28 { id: "browser.search.separatePrivateDefault.ui.enabled", type: "bool" },
29 { id: "browser.urlbar.suggest.trending", type: "bool" },
30 { id: "browser.urlbar.trending.featureGate", type: "bool" },
31 { id: "browser.urlbar.recentsearches.featureGate", type: "bool" },
32 { id: "browser.urlbar.suggest.recentsearches", type: "bool" },
33 { id: "browser.urlbar.scotchBonnet.enableOverride", type: "bool" },
34 ]);
36 const ENGINE_FLAVOR = "text/x-moz-search-engine";
37 const SEARCH_TYPE = "default_search";
38 const SEARCH_KEY = "defaultSearch";
40 var gEngineView = null;
42 var gSearchPane = {
43 _engineStore: null,
44 _engineDropDown: null,
45 _engineDropDownPrivate: null,
47 init() {
48 this._engineStore = new EngineStore();
49 gEngineView = new EngineView(this._engineStore);
51 this._engineDropDown = new DefaultEngineDropDown(
52 "normal",
53 this._engineStore
55 this._engineDropDownPrivate = new DefaultEngineDropDown(
56 "private",
57 this._engineStore
60 this._engineStore.init().catch(console.error);
62 if (
63 Services.policies &&
64 !Services.policies.isAllowed("installSearchEngine")
65 ) {
66 document.getElementById("addEnginesBox").hidden = true;
67 } else {
68 let addEnginesLink = document.getElementById("addEngines");
69 addEnginesLink.setAttribute("href", lazy.SearchUIUtils.searchEnginesURL);
72 window.addEventListener("command", this);
74 Services.obs.addObserver(this, "browser-search-engine-modified");
75 Services.obs.addObserver(this, "intl:app-locales-changed");
76 window.addEventListener("unload", () => {
77 Services.obs.removeObserver(this, "browser-search-engine-modified");
78 Services.obs.removeObserver(this, "intl:app-locales-changed");
79 });
81 let suggestsPref = Preferences.get("browser.search.suggest.enabled");
82 let urlbarSuggestsPref = Preferences.get("browser.urlbar.suggest.searches");
83 let privateSuggestsPref = Preferences.get(
84 "browser.search.suggest.enabled.private"
87 let updateSuggestionCheckboxes =
88 this._updateSuggestionCheckboxes.bind(this);
89 suggestsPref.on("change", updateSuggestionCheckboxes);
90 urlbarSuggestsPref.on("change", updateSuggestionCheckboxes);
91 let customizableUIListener = {
92 onWidgetAfterDOMChange: node => {
93 if (node.id == "search-container") {
94 updateSuggestionCheckboxes();
98 lazy.CustomizableUI.addListener(customizableUIListener);
99 window.addEventListener("unload", () => {
100 lazy.CustomizableUI.removeListener(customizableUIListener);
103 let urlbarSuggests = document.getElementById("urlBarSuggestion");
104 urlbarSuggests.addEventListener("command", () => {
105 urlbarSuggestsPref.value = urlbarSuggests.checked;
107 let suggestionsInSearchFieldsCheckbox = document.getElementById(
108 "suggestionsInSearchFieldsCheckbox"
110 // We only want to call _updateSuggestionCheckboxes once after updating
111 // all prefs.
112 suggestionsInSearchFieldsCheckbox.addEventListener("command", () => {
113 this._skipUpdateSuggestionCheckboxesFromPrefChanges = true;
114 if (!lazy.CustomizableUI.getPlacementOfWidget("search-container")) {
115 urlbarSuggestsPref.value = suggestionsInSearchFieldsCheckbox.checked;
117 suggestsPref.value = suggestionsInSearchFieldsCheckbox.checked;
118 this._skipUpdateSuggestionCheckboxesFromPrefChanges = false;
119 this._updateSuggestionCheckboxes();
121 let privateWindowCheckbox = document.getElementById(
122 "showSearchSuggestionsPrivateWindows"
124 privateWindowCheckbox.addEventListener("command", () => {
125 privateSuggestsPref.value = privateWindowCheckbox.checked;
128 setEventListener(
129 "browserSeparateDefaultEngine",
130 "command",
131 this._onBrowserSeparateDefaultEngineChange.bind(this)
134 this._initDefaultEngines();
135 this._initShowSearchTermsCheckbox();
136 this._updateSuggestionCheckboxes();
137 this._initRecentSeachesCheckbox();
138 this._initAddressBar();
142 * Initialize the default engine handling. This will hide the private default
143 * options if they are not enabled yet.
145 _initDefaultEngines() {
146 this._separatePrivateDefaultEnabledPref = Preferences.get(
147 "browser.search.separatePrivateDefault.ui.enabled"
150 this._separatePrivateDefaultPref = Preferences.get(
151 "browser.search.separatePrivateDefault"
154 const checkbox = document.getElementById("browserSeparateDefaultEngine");
155 checkbox.checked = !this._separatePrivateDefaultPref.value;
157 this._updatePrivateEngineDisplayBoxes();
159 const listener = () => {
160 this._updatePrivateEngineDisplayBoxes();
161 this._engineStore.notifyRebuildViews();
164 this._separatePrivateDefaultEnabledPref.on("change", listener);
165 this._separatePrivateDefaultPref.on("change", listener);
168 _initShowSearchTermsCheckbox() {
169 let checkbox = document.getElementById("searchShowSearchTermCheckbox");
170 let updateCheckboxHidden = () => {
171 checkbox.hidden =
172 !UrlbarPrefs.getScotchBonnetPref("showSearchTerms.featureGate") ||
173 !!lazy.CustomizableUI.getPlacementOfWidget("search-container");
176 // Add observer of CustomizableUI as showSearchTerms checkbox
177 // should be hidden while Search Bar is enabled.
178 let customizableUIListener = {
179 onWidgetAfterDOMChange: node => {
180 if (node.id == "search-container") {
181 updateCheckboxHidden();
185 lazy.CustomizableUI.addListener(customizableUIListener);
187 // Fire once to initialize.
188 updateCheckboxHidden();
190 window.addEventListener("unload", () => {
191 lazy.CustomizableUI.removeListener(customizableUIListener);
195 _updatePrivateEngineDisplayBoxes() {
196 const separateEnabled = this._separatePrivateDefaultEnabledPref.value;
197 document.getElementById("browserSeparateDefaultEngine").hidden =
198 !separateEnabled;
200 const separateDefault = this._separatePrivateDefaultPref.value;
202 const vbox = document.getElementById("browserPrivateEngineSelection");
203 vbox.hidden = !separateEnabled || !separateDefault;
206 _onBrowserSeparateDefaultEngineChange(event) {
207 this._separatePrivateDefaultPref.value = !event.target.checked;
210 _updateSuggestionCheckboxes() {
211 if (this._skipUpdateSuggestionCheckboxesFromPrefChanges) {
212 return;
214 let suggestsPref = Preferences.get("browser.search.suggest.enabled");
215 let permanentPB = Services.prefs.getBoolPref(
216 "browser.privatebrowsing.autostart"
218 let urlbarSuggests = document.getElementById("urlBarSuggestion");
219 let suggestionsInSearchFieldsCheckbox = document.getElementById(
220 "suggestionsInSearchFieldsCheckbox"
222 let positionCheckbox = document.getElementById(
223 "showSearchSuggestionsFirstCheckbox"
225 let privateWindowCheckbox = document.getElementById(
226 "showSearchSuggestionsPrivateWindows"
228 let urlbarSuggestsPref = Preferences.get("browser.urlbar.suggest.searches");
229 let searchBarVisible =
230 !!lazy.CustomizableUI.getPlacementOfWidget("search-container");
232 suggestionsInSearchFieldsCheckbox.checked =
233 suggestsPref.value && (searchBarVisible || urlbarSuggestsPref.value);
235 urlbarSuggests.disabled = !suggestsPref.value || permanentPB;
236 urlbarSuggests.hidden = !searchBarVisible;
238 privateWindowCheckbox.disabled = !suggestsPref.value;
239 privateWindowCheckbox.checked = Preferences.get(
240 "browser.search.suggest.enabled.private"
241 ).value;
242 if (privateWindowCheckbox.disabled) {
243 privateWindowCheckbox.checked = false;
246 urlbarSuggests.checked = urlbarSuggestsPref.value;
247 if (urlbarSuggests.disabled) {
248 urlbarSuggests.checked = false;
250 if (urlbarSuggests.checked) {
251 positionCheckbox.disabled = false;
252 // Update the checked state of the show-suggestions-first checkbox. Note
253 // that this does *not* also update its pref, it only checks the box.
254 positionCheckbox.checked = Preferences.get(
255 positionCheckbox.getAttribute("preference")
256 ).value;
257 } else {
258 positionCheckbox.disabled = true;
259 positionCheckbox.checked = false;
261 if (
262 suggestionsInSearchFieldsCheckbox.checked &&
263 !searchBarVisible &&
264 !urlbarSuggests.checked
266 urlbarSuggestsPref.value = true;
269 let permanentPBLabel = document.getElementById(
270 "urlBarSuggestionPermanentPBLabel"
272 permanentPBLabel.hidden = urlbarSuggests.hidden || !permanentPB;
274 this._updateTrendingCheckbox(!suggestsPref.value || permanentPB);
277 _initRecentSeachesCheckbox() {
278 this._recentSearchesEnabledPref = Preferences.get(
279 "browser.urlbar.recentsearches.featureGate"
281 let recentSearchesCheckBox = document.getElementById(
282 "enableRecentSearches"
284 const listener = () => {
285 recentSearchesCheckBox.hidden = !this._recentSearchesEnabledPref.value;
288 this._recentSearchesEnabledPref.on("change", listener);
289 listener();
292 async _updateTrendingCheckbox(suggestDisabled) {
293 let trendingBox = document.getElementById("showTrendingSuggestionsBox");
294 let trendingCheckBox = document.getElementById("showTrendingSuggestions");
295 let trendingSupported = (
296 await Services.search.getDefault()
297 ).supportsResponseType(lazy.SearchUtils.URL_TYPE.TRENDING_JSON);
298 trendingBox.hidden = !Preferences.get("browser.urlbar.trending.featureGate")
299 .value;
300 trendingCheckBox.disabled = suggestDisabled || !trendingSupported;
303 // ADDRESS BAR
306 * Initializes the address bar section.
308 _initAddressBar() {
309 // Update the Firefox Suggest section when its Nimbus config changes.
310 let onNimbus = () => this._updateFirefoxSuggestSection();
311 NimbusFeatures.urlbar.onUpdate(onNimbus);
312 window.addEventListener("unload", () => {
313 NimbusFeatures.urlbar.offUpdate(onNimbus);
316 // The Firefox Suggest info box potentially needs updating when any of the
317 // toggles change.
318 let infoBoxPrefs = [
319 "browser.urlbar.suggest.quicksuggest.nonsponsored",
320 "browser.urlbar.suggest.quicksuggest.sponsored",
321 "browser.urlbar.quicksuggest.dataCollection.enabled",
323 for (let pref of infoBoxPrefs) {
324 Preferences.get(pref).on("change", () =>
325 this._updateFirefoxSuggestInfoBox()
329 document.getElementById("clipboardSuggestion").hidden = !UrlbarPrefs.get(
330 "clipboard.featureGate"
333 this._updateFirefoxSuggestSection(true);
334 this._initQuickActionsSection();
338 * Updates the Firefox Suggest section (in the address bar section) depending
339 * on whether the user is enrolled in a Firefox Suggest rollout.
341 * @param {boolean} [onInit]
342 * Pass true when calling this when initializing the pane.
344 _updateFirefoxSuggestSection(onInit = false) {
345 let container = document.getElementById("firefoxSuggestContainer");
347 if (
348 UrlbarPrefs.get("quickSuggestEnabled") &&
349 !UrlbarPrefs.get("quickSuggestHideSettingsUI")
351 // Update the l10n IDs of text elements.
352 let l10nIdByElementId = {
353 locationBarGroupHeader: "addressbar-header-firefox-suggest",
354 locationBarSuggestionLabel: "addressbar-suggest-firefox-suggest",
356 for (let [elementId, l10nId] of Object.entries(l10nIdByElementId)) {
357 let element = document.getElementById(elementId);
358 element.dataset.l10nIdOriginal ??= element.dataset.l10nId;
359 element.dataset.l10nId = l10nId;
362 // Show the container.
363 this._updateFirefoxSuggestInfoBox();
365 this._updateDismissedSuggestionsStatus();
366 Preferences.get(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST).on("change", () =>
367 this._updateDismissedSuggestionsStatus()
369 Preferences.get(PREF_URLBAR_WEATHER_USER_ENABLED).on("change", () =>
370 this._updateDismissedSuggestionsStatus()
372 setEventListener("restoreDismissedSuggestions", "command", () =>
373 this.restoreDismissedSuggestions()
376 container.removeAttribute("hidden");
377 } else if (!onInit) {
378 // Firefox Suggest is not enabled. This is the default, so to avoid
379 // accidentally messing anything up, only modify the doc if we're being
380 // called due to a change in the rollout-enabled status (!onInit).
381 container.setAttribute("hidden", "true");
382 let elementIds = ["locationBarGroupHeader", "locationBarSuggestionLabel"];
383 for (let id of elementIds) {
384 let element = document.getElementById(id);
385 if (element.dataset.l10nIdOriginal) {
386 document.l10n.setAttributes(element, element.dataset.l10nIdOriginal);
387 delete element.dataset.l10nIdOriginal;
394 * Updates the Firefox Suggest info box (in the address bar section) depending
395 * on the states of the Firefox Suggest toggles.
397 _updateFirefoxSuggestInfoBox() {
398 let nonsponsored = Preferences.get(
399 "browser.urlbar.suggest.quicksuggest.nonsponsored"
400 ).value;
401 let sponsored = Preferences.get(
402 "browser.urlbar.suggest.quicksuggest.sponsored"
403 ).value;
404 let dataCollection = Preferences.get(
405 "browser.urlbar.quicksuggest.dataCollection.enabled"
406 ).value;
408 // Get the l10n ID of the appropriate text based on the values of the three
409 // prefs.
410 let l10nId;
411 if (nonsponsored && sponsored && dataCollection) {
412 l10nId = "addressbar-firefox-suggest-info-all";
413 } else if (nonsponsored && sponsored && !dataCollection) {
414 l10nId = "addressbar-firefox-suggest-info-nonsponsored-sponsored";
415 } else if (nonsponsored && !sponsored && dataCollection) {
416 l10nId = "addressbar-firefox-suggest-info-nonsponsored-data";
417 } else if (nonsponsored && !sponsored && !dataCollection) {
418 l10nId = "addressbar-firefox-suggest-info-nonsponsored";
419 } else if (!nonsponsored && sponsored && dataCollection) {
420 l10nId = "addressbar-firefox-suggest-info-sponsored-data";
421 } else if (!nonsponsored && sponsored && !dataCollection) {
422 l10nId = "addressbar-firefox-suggest-info-sponsored";
423 } else if (!nonsponsored && !sponsored && dataCollection) {
424 l10nId = "addressbar-firefox-suggest-info-data";
427 let instance = (this._firefoxSuggestInfoBoxInstance = {});
428 let infoBox = document.getElementById("firefoxSuggestInfoBox");
429 if (!l10nId) {
430 infoBox.hidden = true;
431 } else {
432 let infoText = document.getElementById("firefoxSuggestInfoText");
433 infoText.dataset.l10nId = l10nId;
435 // If the info box is currently hidden and we unhide it immediately, it
436 // will show its old text until the new text is asyncly fetched and shown.
437 // That's ugly, so wait for the fetch to finish before unhiding it.
438 document.l10n.translateElements([infoText]).then(() => {
439 if (instance == this._firefoxSuggestInfoBoxInstance) {
440 infoBox.hidden = false;
446 _initQuickActionsSection() {
447 let showPref = Preferences.get("browser.urlbar.quickactions.showPrefs");
448 let scotchBonnet = Preferences.get(
449 "browser.urlbar.scotchBonnet.enableOverride"
451 let showQuickActionsGroup = () => {
452 document.getElementById("quickActionsBox").hidden = !(
453 showPref.value || scotchBonnet.value
456 showPref.on("change", showQuickActionsGroup);
457 showQuickActionsGroup();
461 * Enables/disables the "Restore" button for dismissed Firefox Suggest
462 * suggestions.
464 _updateDismissedSuggestionsStatus() {
465 document.getElementById("restoreDismissedSuggestions").disabled =
466 !Services.prefs.prefHasUserValue(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST) &&
468 Services.prefs.prefHasUserValue(PREF_URLBAR_WEATHER_USER_ENABLED) &&
469 !Services.prefs.getBoolPref(PREF_URLBAR_WEATHER_USER_ENABLED)
474 * Restores Firefox Suggest suggestions dismissed by the user.
476 restoreDismissedSuggestions() {
477 Services.prefs.clearUserPref(PREF_URLBAR_QUICKSUGGEST_BLOCKLIST);
478 Services.prefs.clearUserPref(PREF_URLBAR_WEATHER_USER_ENABLED);
481 handleEvent(aEvent) {
482 if (aEvent.type != "command") {
483 return;
485 switch (aEvent.target.id) {
486 case "":
487 if (aEvent.target.parentNode && aEvent.target.parentNode.parentNode) {
488 if (aEvent.target.parentNode.parentNode.id == "defaultEngine") {
489 gSearchPane.setDefaultEngine();
490 } else if (
491 aEvent.target.parentNode.parentNode.id == "defaultPrivateEngine"
493 gSearchPane.setDefaultPrivateEngine();
496 break;
497 default:
498 gEngineView.handleEvent(aEvent);
503 * Handle when the app locale is changed.
505 async appLocalesChanged() {
506 await document.l10n.ready;
507 await gEngineView.loadL10nNames();
511 * nsIObserver implementation.
513 observe(subject, topic, data) {
514 switch (topic) {
515 case "intl:app-locales-changed": {
516 this.appLocalesChanged();
517 break;
519 case "browser-search-engine-modified": {
520 let engine = subject.QueryInterface(Ci.nsISearchEngine);
521 switch (data) {
522 case "engine-default": {
523 // Pass through to the engine store to handle updates.
524 this._engineStore.browserSearchEngineModified(engine, data);
525 gSearchPane._updateSuggestionCheckboxes();
526 break;
528 default:
529 this._engineStore.browserSearchEngineModified(engine, data);
535 showRestoreDefaults(aEnable) {
536 document.getElementById("restoreDefaultSearchEngines").disabled = !aEnable;
539 async setDefaultEngine() {
540 await Services.search.setDefault(
541 document.getElementById("defaultEngine").selectedItem.engine,
542 Ci.nsISearchService.CHANGE_REASON_USER
544 if (ExtensionSettingsStore.getSetting(SEARCH_TYPE, SEARCH_KEY) !== null) {
545 ExtensionSettingsStore.select(
546 ExtensionSettingsStore.SETTING_USER_SET,
547 SEARCH_TYPE,
548 SEARCH_KEY
553 async setDefaultPrivateEngine() {
554 await Services.search.setDefaultPrivate(
555 document.getElementById("defaultPrivateEngine").selectedItem.engine,
556 Ci.nsISearchService.CHANGE_REASON_USER
562 * Keeps track of the search engine objects and notifies the views for updates.
564 class EngineStore {
566 * A list of engines that are currently visible in the UI.
568 * @type {Object[]}
570 engines = [];
573 * A list of application provided engines used when restoring the list of
574 * engines to the default set and order.
576 * @type {nsISearchEngine[]}
578 #appProvidedEngines = [];
581 * A list of listeners to be notified when the engine list changes.
583 * @type {Object[]}
585 #listeners = [];
587 async init() {
588 let visibleEngines = await Services.search.getVisibleEngines();
589 for (let engine of visibleEngines) {
590 this.addEngine(engine);
593 let appProvidedEngines = await Services.search.getAppProvidedEngines();
594 this.#appProvidedEngines = appProvidedEngines.map(this._cloneEngine, this);
596 this.notifyRowCountChanged(0, visibleEngines.length);
598 // check if we need to disable the restore defaults button
599 var someHidden = this.#appProvidedEngines.some(e => e.hidden);
600 gSearchPane.showRestoreDefaults(someHidden);
604 * Adds a listener to be notified when the engine list changes.
606 * @param {object} aListener
608 addListener(aListener) {
609 this.#listeners.push(aListener);
613 * Notifies all listeners that the engine list has changed and they should
614 * rebuild.
616 notifyRebuildViews() {
617 for (let listener of this.#listeners) {
618 try {
619 listener.rebuild(this.engines);
620 } catch (ex) {
621 console.error("Error notifying EngineStore listener", ex);
627 * Notifies all listeners that the number of engines in the list has changed.
629 * @param {number} index
630 * @param {number} count
632 notifyRowCountChanged(index, count) {
633 for (let listener of this.#listeners) {
634 listener.rowCountChanged(index, count, this.engines);
639 * Notifies all listeners that the default engine has changed.
641 * @param {string} type
642 * @param {object} engine
644 notifyDefaultEngineChanged(type, engine) {
645 for (let listener of this.#listeners) {
646 if ("defaultEngineChanged" in listener) {
647 listener.defaultEngineChanged(type, engine, this.engines);
652 notifyEngineIconUpdated(engine) {
653 // Check the engine is still in the list.
654 let index = this._getIndexForEngine(engine);
655 if (index != -1) {
656 for (let listener of this.#listeners) {
657 listener.engineIconUpdated(index, this.engines);
662 _getIndexForEngine(aEngine) {
663 return this.engines.indexOf(aEngine);
666 _getEngineByName(aName) {
667 return this.engines.find(engine => engine.name == aName);
670 _cloneEngine(aEngine) {
671 var clonedObj = {
672 iconURL: null,
674 for (let i of ["id", "name", "alias", "hidden"]) {
675 clonedObj[i] = aEngine[i];
677 clonedObj.originalEngine = aEngine;
679 // Trigger getting the iconURL for this engine.
680 aEngine.getIconURL().then(iconURL => {
681 if (iconURL) {
682 clonedObj.iconURL = iconURL;
683 } else if (window.devicePixelRatio > 1) {
684 clonedObj.iconURL =
685 "chrome://browser/skin/search-engine-placeholder@2x.png";
686 } else {
687 clonedObj.iconURL =
688 "chrome://browser/skin/search-engine-placeholder.png";
691 this.notifyEngineIconUpdated(clonedObj);
694 return clonedObj;
697 // Callback for Array's some(). A thisObj must be passed to some()
698 _isSameEngine(aEngineClone) {
699 return aEngineClone.originalEngine.id == this.originalEngine.id;
702 addEngine(aEngine) {
703 this.engines.push(this._cloneEngine(aEngine));
706 updateEngine(newEngine) {
707 let engineToUpdate = this.engines.findIndex(
708 e => e.originalEngine.id == newEngine.id
710 if (engineToUpdate != -1) {
711 this.engines[engineToUpdate] = this._cloneEngine(newEngine);
715 moveEngine(aEngine, aNewIndex) {
716 if (aNewIndex < 0 || aNewIndex > this.engines.length - 1) {
717 throw new Error("ES_moveEngine: invalid aNewIndex!");
719 var index = this._getIndexForEngine(aEngine);
720 if (index == -1) {
721 throw new Error("ES_moveEngine: invalid engine?");
724 if (index == aNewIndex) {
725 return Promise.resolve();
726 } // nothing to do
728 // Move the engine in our internal store
729 var removedEngine = this.engines.splice(index, 1)[0];
730 this.engines.splice(aNewIndex, 0, removedEngine);
732 return Services.search.moveEngine(aEngine.originalEngine, aNewIndex);
735 removeEngine(aEngine) {
736 if (this.engines.length == 1) {
737 throw new Error("Cannot remove last engine!");
740 let engineName = aEngine.name;
741 let index = this.engines.findIndex(element => element.name == engineName);
743 if (index == -1) {
744 throw new Error("invalid engine?");
747 this.engines.splice(index, 1)[0];
749 if (aEngine.isAppProvided) {
750 gSearchPane.showRestoreDefaults(true);
753 this.notifyRowCountChanged(index, -1);
755 document.getElementById("engineList").focus();
759 * Update the default engine UI and engine tree view as appropriate when engine changes
760 * or locale changes occur.
762 * @param {nsISearchEngine} engine
763 * @param {string} data
765 browserSearchEngineModified(engine, data) {
766 engine.QueryInterface(Ci.nsISearchEngine);
767 switch (data) {
768 case "engine-added":
769 this.addEngine(engine);
770 this.notifyRowCountChanged(gEngineView.lastEngineIndex, 1);
771 break;
772 case "engine-changed":
773 case "engine-icon-changed":
774 this.updateEngine(engine);
775 this.notifyRebuildViews();
776 break;
777 case "engine-removed":
778 this.removeEngine(engine);
779 break;
780 case "engine-default":
781 this.notifyDefaultEngineChanged("normal", engine);
782 break;
783 case "engine-default-private":
784 this.notifyDefaultEngineChanged("private", engine);
785 break;
789 async restoreDefaultEngines() {
790 var added = 0;
792 for (var i = 0; i < this.#appProvidedEngines.length; ++i) {
793 var e = this.#appProvidedEngines[i];
795 // If the engine is already in the list, just move it.
796 if (this.engines.some(this._isSameEngine, e)) {
797 await this.moveEngine(this._getEngineByName(e.name), i);
798 } else {
799 // Otherwise, add it back to our internal store
801 // The search service removes the alias when an engine is hidden,
802 // so clear any alias we may have cached before unhiding the engine.
803 e.alias = "";
805 this.engines.splice(i, 0, e);
806 let engine = e.originalEngine;
807 engine.hidden = false;
808 await Services.search.moveEngine(engine, i);
809 added++;
813 // We can't do this as part of the loop above because the indices are
814 // used for moving engines.
815 let policyRemovedEngineNames =
816 Services.policies.getActivePolicies()?.SearchEngines?.Remove || [];
817 for (let engineName of policyRemovedEngineNames) {
818 let engine = Services.search.getEngineByName(engineName);
819 if (engine) {
820 try {
821 await Services.search.removeEngine(engine);
822 } catch (ex) {
823 // Engine might not exist
828 Services.search.resetToAppDefaultEngine();
829 gSearchPane.showRestoreDefaults(false);
830 this.notifyRebuildViews();
831 return added;
834 changeEngine(aEngine, aProp, aNewValue) {
835 var index = this._getIndexForEngine(aEngine);
836 if (index == -1) {
837 throw new Error("invalid engine?");
840 this.engines[index][aProp] = aNewValue;
841 aEngine.originalEngine[aProp] = aNewValue;
846 * Manages the view of the Search Shortcuts tree on the search pane of preferences.
848 class EngineView {
849 _engineStore = null;
850 _engineList = null;
851 tree = null;
853 constructor(aEngineStore) {
854 this._engineStore = aEngineStore;
855 this._engineList = document.getElementById("engineList");
856 this._engineList.view = this;
858 UrlbarPrefs.addObserver(this);
859 aEngineStore.addListener(this);
861 this.loadL10nNames();
862 this.#addListeners();
863 this.#showAddEngineButton();
866 async loadL10nNames() {
867 // This maps local shortcut sources to their l10n names. The names are needed
868 // by getCellText. Getting the names is async but getCellText is not, so we
869 // cache them here to retrieve them syncronously in getCellText.
870 this._localShortcutL10nNames = new Map();
872 let getIDs = (suffix = "") =>
873 UrlbarUtils.LOCAL_SEARCH_MODES.map(mode => {
874 let name = UrlbarUtils.getResultSourceName(mode.source);
875 return { id: `urlbar-search-mode-${name}${suffix}` };
878 try {
879 let localizedIDs = getIDs();
880 let englishIDs = getIDs("-en");
882 let englishSearchStrings = new Localization([
883 "preview/enUS-searchFeatures.ftl",
885 let localizedNames = await document.l10n.formatValues(localizedIDs);
886 let englishNames = await englishSearchStrings.formatValues(englishIDs);
888 UrlbarUtils.LOCAL_SEARCH_MODES.forEach(({ source }, index) => {
889 let localizedName = localizedNames[index];
890 let englishName = englishNames[index];
892 // Add only the English name if localized and English are the same
893 let names =
894 localizedName === englishName
895 ? [englishName]
896 : [localizedName, englishName];
898 this._localShortcutL10nNames.set(source, names);
900 // Invalidate the tree now that we have the names in case getCellText was
901 // called before name retrieval finished.
902 this.invalidate();
904 } catch (ex) {
905 console.error("Error loading l10n names", ex);
909 #addListeners() {
910 this._engineList.addEventListener("click", this);
911 this._engineList.addEventListener("dragstart", this);
912 this._engineList.addEventListener("keypress", this);
913 this._engineList.addEventListener("select", this);
914 this._engineList.addEventListener("dblclick", this);
918 * Shows the "Add Search Engine" button if the pref is enabled.
920 #showAddEngineButton() {
921 let aliasRefresh = Services.prefs.getBoolPref(
922 "browser.urlbar.update2.engineAliasRefresh",
923 false
925 if (aliasRefresh) {
926 let addButton = document.getElementById("addEngineButton");
927 addButton.hidden = false;
931 get lastEngineIndex() {
932 return this._engineStore.engines.length - 1;
935 get selectedIndex() {
936 var seln = this.selection;
937 if (seln.getRangeCount() > 0) {
938 var min = {};
939 seln.getRangeAt(0, min, {});
940 return min.value;
942 return -1;
945 get selectedEngine() {
946 return this._engineStore.engines[this.selectedIndex];
949 // Helpers
950 rebuild() {
951 this.invalidate();
954 rowCountChanged(index, count) {
955 if (!this.tree) {
956 return;
958 this.tree.rowCountChanged(index, count);
960 // If we're removing elements, ensure that we still have a selection.
961 if (count < 0) {
962 this.selection.select(Math.min(index, this.rowCount - 1));
963 this.ensureRowIsVisible(this.currentIndex);
967 engineIconUpdated(index) {
968 this.tree?.invalidateCell(
969 index,
970 this.tree.columns.getNamedColumn("engineName")
974 invalidate() {
975 this.tree?.invalidate();
978 ensureRowIsVisible(index) {
979 this.tree.ensureRowIsVisible(index);
982 getSourceIndexFromDrag(dataTransfer) {
983 return parseInt(dataTransfer.getData(ENGINE_FLAVOR));
986 isCheckBox(index, column) {
987 return column.id == "engineShown";
990 isEngineSelectedAndRemovable() {
991 let defaultEngine = Services.search.defaultEngine;
992 let defaultPrivateEngine = Services.search.defaultPrivateEngine;
993 // We don't allow the last remaining engine to be removed, thus the
994 // `this.lastEngineIndex != 0` check.
995 // We don't allow the default engine to be removed.
996 return (
997 this.selectedIndex != -1 &&
998 this.lastEngineIndex != 0 &&
999 !this._getLocalShortcut(this.selectedIndex) &&
1000 this.selectedEngine.name != defaultEngine.name &&
1001 this.selectedEngine.name != defaultPrivateEngine.name
1006 * Returns the local shortcut corresponding to a tree row, or null if the row
1007 * is not a local shortcut.
1009 * @param {number} index
1010 * The tree row index.
1011 * @returns {object}
1012 * The local shortcut object or null if the row is not a local shortcut.
1014 _getLocalShortcut(index) {
1015 let engineCount = this._engineStore.engines.length;
1016 if (index < engineCount) {
1017 return null;
1019 return UrlbarUtils.LOCAL_SEARCH_MODES[index - engineCount];
1023 * Called by UrlbarPrefs when a urlbar pref changes.
1025 * @param {string} pref
1026 * The name of the pref relative to the browser.urlbar branch.
1028 onPrefChanged(pref) {
1029 // If one of the local shortcut prefs was toggled, toggle its row's
1030 // checkbox.
1031 let parts = pref.split(".");
1032 if (parts[0] == "shortcuts" && parts[1] && parts.length == 2) {
1033 this.invalidate();
1037 handleEvent(aEvent) {
1038 switch (aEvent.type) {
1039 case "dblclick":
1040 if (aEvent.target.id == "engineChildren") {
1041 let cell = aEvent.target.parentNode.getCellAt(
1042 aEvent.clientX,
1043 aEvent.clientY
1045 if (cell.col?.id == "engineKeyword") {
1046 this.#startEditingAlias(this.selectedIndex);
1049 break;
1050 case "click":
1051 if (
1052 aEvent.target.id != "engineChildren" &&
1053 !aEvent.target.classList.contains("searchEngineAction")
1055 // We don't want to toggle off selection while editing keyword
1056 // so proceed only when the input field is hidden.
1057 // We need to check that engineList.view is defined here
1058 // because the "click" event listener is on <window> and the
1059 // view might have been destroyed if the pane has been navigated
1060 // away from.
1061 if (this._engineList.inputField.hidden && this._engineList.view) {
1062 let selection = this._engineList.view.selection;
1063 if (selection?.count > 0) {
1064 selection.toggleSelect(selection.currentIndex);
1066 this._engineList.blur();
1069 break;
1070 case "command":
1071 switch (aEvent.target.id) {
1072 case "restoreDefaultSearchEngines":
1073 this.#onRestoreDefaults();
1074 break;
1075 case "removeEngineButton":
1076 Services.search.removeEngine(this.selectedEngine.originalEngine);
1077 break;
1078 case "addEngineButton":
1079 gSubDialog.open(
1080 "chrome://browser/content/preferences/dialogs/addEngine.xhtml",
1081 { features: "resizable=no, modal=yes" }
1083 break;
1085 break;
1086 case "dragstart":
1087 if (aEvent.target.id == "engineChildren") {
1088 this.#onDragEngineStart(aEvent);
1090 break;
1091 case "keypress":
1092 if (aEvent.target.id == "engineList") {
1093 this.#onTreeKeyPress(aEvent);
1095 break;
1096 case "select":
1097 if (aEvent.target.id == "engineList") {
1098 this.#onTreeSelect();
1100 break;
1105 * Called when the restore default engines button is clicked to reset the
1106 * list of engines to their defaults.
1108 async #onRestoreDefaults() {
1109 let num = await this._engineStore.restoreDefaultEngines();
1110 this.rowCountChanged(0, num);
1113 #onDragEngineStart(event) {
1114 let selectedIndex = this.selectedIndex;
1116 // Local shortcut rows can't be dragged or re-ordered.
1117 if (this._getLocalShortcut(selectedIndex)) {
1118 event.preventDefault();
1119 return;
1122 let tree = document.getElementById("engineList");
1123 let cell = tree.getCellAt(event.clientX, event.clientY);
1124 if (selectedIndex >= 0 && !this.isCheckBox(cell.row, cell.col)) {
1125 event.dataTransfer.setData(ENGINE_FLAVOR, selectedIndex.toString());
1126 event.dataTransfer.effectAllowed = "move";
1130 #onTreeSelect() {
1131 document.getElementById("removeEngineButton").disabled =
1132 !this.isEngineSelectedAndRemovable();
1135 #onTreeKeyPress(aEvent) {
1136 let index = this.selectedIndex;
1137 let tree = document.getElementById("engineList");
1138 if (tree.hasAttribute("editing")) {
1139 return;
1142 if (aEvent.charCode == KeyEvent.DOM_VK_SPACE) {
1143 // Space toggles the checkbox.
1144 let newValue = !this.getCellValue(
1145 index,
1146 tree.columns.getNamedColumn("engineShown")
1148 this.setCellValue(
1149 index,
1150 tree.columns.getFirstColumn(),
1151 newValue.toString()
1153 // Prevent page from scrolling on the space key.
1154 aEvent.preventDefault();
1155 } else {
1156 let isMac = Services.appinfo.OS == "Darwin";
1157 if (
1158 (isMac && aEvent.keyCode == KeyEvent.DOM_VK_RETURN) ||
1159 (!isMac && aEvent.keyCode == KeyEvent.DOM_VK_F2)
1161 this.#startEditingAlias(index);
1162 } else if (
1163 aEvent.keyCode == KeyEvent.DOM_VK_DELETE ||
1164 (isMac &&
1165 aEvent.shiftKey &&
1166 aEvent.keyCode == KeyEvent.DOM_VK_BACK_SPACE &&
1167 this.isEngineSelectedAndRemovable())
1169 // Delete and Shift+Backspace (Mac) removes selected engine.
1170 Services.search.removeEngine(this.selectedEngine.originalEngine);
1176 * Triggers editing of an alias in the tree.
1178 * @param {number} index
1180 #startEditingAlias(index) {
1181 // Local shortcut aliases can't be edited.
1182 if (this._getLocalShortcut(index)) {
1183 return;
1186 let tree = document.getElementById("engineList");
1187 let engine = this._engineStore.engines[index];
1188 tree.startEditing(index, tree.columns.getLastColumn());
1189 tree.inputField.value = engine.alias || "";
1190 tree.inputField.select();
1193 // nsITreeView
1194 get rowCount() {
1195 let localModes = UrlbarUtils.LOCAL_SEARCH_MODES;
1196 if (!lazy.UrlbarPrefs.get("scotchBonnet.enableOverride")) {
1197 localModes = localModes.filter(
1198 mode => mode.source != UrlbarUtils.RESULT_SOURCE.ACTIONS
1201 return this._engineStore.engines.length + localModes.length;
1204 getImageSrc(index, column) {
1205 if (column.id == "engineName") {
1206 let shortcut = this._getLocalShortcut(index);
1207 if (shortcut) {
1208 return shortcut.icon;
1211 return this._engineStore.engines[index].iconURL;
1214 return "";
1217 getCellText(index, column) {
1218 if (column.id == "engineName") {
1219 let shortcut = this._getLocalShortcut(index);
1220 if (shortcut) {
1221 return this._localShortcutL10nNames.get(shortcut.source)[0] || "";
1223 return this._engineStore.engines[index].name;
1224 } else if (column.id == "engineKeyword") {
1225 let shortcut = this._getLocalShortcut(index);
1226 if (shortcut) {
1227 if (
1228 lazy.UrlbarPrefs.getScotchBonnetPref(
1229 "searchRestrictKeywords.featureGate"
1232 let keywords = this._localShortcutL10nNames
1233 .get(shortcut.source)
1234 .map(keyword => `@${keyword.toLowerCase()}`)
1235 .join(", ");
1237 return `${keywords}, ${shortcut.restrict}`;
1240 return shortcut.restrict;
1242 return this._engineStore.engines[index].originalEngine.aliases.join(", ");
1244 return "";
1247 setTree(tree) {
1248 this.tree = tree;
1251 canDrop(targetIndex, orientation, dataTransfer) {
1252 var sourceIndex = this.getSourceIndexFromDrag(dataTransfer);
1253 return (
1254 sourceIndex != -1 &&
1255 sourceIndex != targetIndex &&
1256 sourceIndex != targetIndex + orientation &&
1257 // Local shortcut rows can't be dragged or dropped on.
1258 targetIndex < this._engineStore.engines.length
1262 async drop(dropIndex, orientation, dataTransfer) {
1263 // Local shortcut rows can't be dragged or dropped on. This can sometimes
1264 // be reached even though canDrop returns false for these rows.
1265 if (this._engineStore.engines.length <= dropIndex) {
1266 return;
1269 var sourceIndex = this.getSourceIndexFromDrag(dataTransfer);
1270 var sourceEngine = this._engineStore.engines[sourceIndex];
1272 const nsITreeView = Ci.nsITreeView;
1273 if (dropIndex > sourceIndex) {
1274 if (orientation == nsITreeView.DROP_BEFORE) {
1275 dropIndex--;
1277 } else if (orientation == nsITreeView.DROP_AFTER) {
1278 dropIndex++;
1281 await this._engineStore.moveEngine(sourceEngine, dropIndex);
1282 gSearchPane.showRestoreDefaults(true);
1284 // Redraw, and adjust selection
1285 this.invalidate();
1286 this.selection.select(dropIndex);
1289 selection = null;
1290 getRowProperties() {
1291 return "";
1293 getCellProperties(index, column) {
1294 if (column.id == "engineName") {
1295 // For local shortcut rows, return the result source name so we can style
1296 // the icons in CSS.
1297 let shortcut = this._getLocalShortcut(index);
1298 if (shortcut) {
1299 return UrlbarUtils.getResultSourceName(shortcut.source);
1302 return "";
1304 getColumnProperties() {
1305 return "";
1307 isContainer() {
1308 return false;
1310 isContainerOpen() {
1311 return false;
1313 isContainerEmpty() {
1314 return false;
1316 isSeparator() {
1317 return false;
1319 isSorted() {
1320 return false;
1322 getParentIndex() {
1323 return -1;
1325 hasNextSibling() {
1326 return false;
1328 getLevel() {
1329 return 0;
1331 getCellValue(index, column) {
1332 if (column.id == "engineShown") {
1333 let shortcut = this._getLocalShortcut(index);
1334 if (shortcut) {
1335 return UrlbarPrefs.get(shortcut.pref);
1337 return !this._engineStore.engines[index].originalEngine.hideOneOffButton;
1339 return undefined;
1341 toggleOpenState() {}
1342 cycleHeader() {}
1343 selectionChanged() {}
1344 cycleCell() {}
1345 isEditable(index, column) {
1346 return (
1347 column.id != "engineName" &&
1348 (column.id == "engineShown" || !this._getLocalShortcut(index))
1351 setCellValue(index, column, value) {
1352 if (column.id == "engineShown") {
1353 let shortcut = this._getLocalShortcut(index);
1354 if (shortcut) {
1355 UrlbarPrefs.set(shortcut.pref, value == "true");
1356 this.invalidate();
1357 return;
1359 this._engineStore.engines[index].originalEngine.hideOneOffButton =
1360 value != "true";
1361 this.invalidate();
1364 setCellText(index, column, value) {
1365 if (column.id == "engineKeyword") {
1366 this.#changeKeyword(this._engineStore.engines[index], value).then(
1367 valid => {
1368 if (!valid) {
1369 this.#startEditingAlias(index);
1377 * Handles changing the keyword for an engine. This will check for potentially
1378 * duplicate keywords and prompt the user if necessary.
1380 * @param {object} aEngine
1381 * The engine to change.
1382 * @param {string} aNewKeyword
1383 * The new keyword.
1384 * @returns {Promise<boolean>}
1385 * Resolves to true if the keyword was changed.
1387 async #changeKeyword(aEngine, aNewKeyword) {
1388 let keyword = aNewKeyword.trim();
1389 if (keyword) {
1390 let eduplicate = false;
1391 let dupName = "";
1393 // Check for duplicates in Places keywords.
1394 let bduplicate = !!(await PlacesUtils.keywords.fetch(keyword));
1396 // Check for duplicates in changes we haven't committed yet
1397 let engines = this._engineStore.engines;
1398 let lc_keyword = keyword.toLocaleLowerCase();
1399 for (let engine of engines) {
1400 if (
1401 engine.alias &&
1402 engine.alias.toLocaleLowerCase() == lc_keyword &&
1403 engine.name != aEngine.name
1405 eduplicate = true;
1406 dupName = engine.name;
1407 break;
1411 // Notify the user if they have chosen an existing engine/bookmark keyword
1412 if (eduplicate || bduplicate) {
1413 let msgids = [{ id: "search-keyword-warning-title" }];
1414 if (eduplicate) {
1415 msgids.push({
1416 id: "search-keyword-warning-engine",
1417 args: { name: dupName },
1419 } else {
1420 msgids.push({ id: "search-keyword-warning-bookmark" });
1423 let [dtitle, msg] = await document.l10n.formatValues(msgids);
1425 Services.prompt.alert(window, dtitle, msg);
1426 return false;
1430 this._engineStore.changeEngine(aEngine, "alias", keyword);
1431 this.invalidate();
1432 return true;
1437 * Manages the default engine dropdown buttons in the search pane of preferences.
1439 class DefaultEngineDropDown {
1440 #element = null;
1441 #type = null;
1443 constructor(type, engineStore) {
1444 this.#type = type;
1445 this.#element = document.getElementById(
1446 type == "private" ? "defaultPrivateEngine" : "defaultEngine"
1449 engineStore.addListener(this);
1452 rowCountChanged(index, count, enginesList) {
1453 // Simply rebuild the menulist, rather than trying to update the changed row.
1454 this.rebuild(enginesList);
1457 defaultEngineChanged(type, engine, enginesList) {
1458 if (type != this.#type) {
1459 return;
1461 // If the user is going through the drop down using up/down keys, the
1462 // dropdown may still be open (eg. on Windows) when engine-default is
1463 // fired, so rebuilding the list unconditionally would get in the way.
1464 let selectedEngineName = this.#element.selectedItem?.engine?.name;
1465 if (selectedEngineName != engine.name) {
1466 this.rebuild(enginesList);
1470 engineIconUpdated(index, enginesList) {
1471 let item = this.#element.getItemAtIndex(index);
1472 // Check this is the right item.
1473 if (item?.label == enginesList[index].name) {
1474 item.setAttribute("image", enginesList[index].iconURL);
1478 async rebuild(enginesList) {
1479 if (
1480 this.#type == "private" &&
1481 !gSearchPane._separatePrivateDefaultPref.value
1483 return;
1485 let defaultEngine =
1486 await Services.search[
1487 this.#type == "normal" ? "getDefault" : "getDefaultPrivate"
1488 ]();
1490 this.#element.removeAllItems();
1491 for (let engine of enginesList) {
1492 let item = this.#element.appendItem(engine.name);
1493 item.setAttribute(
1494 "class",
1495 "menuitem-iconic searchengine-menuitem menuitem-with-favicon"
1497 if (engine.iconURL) {
1498 item.setAttribute("image", engine.iconURL);
1500 item.engine = engine;
1501 if (engine.name == defaultEngine.name) {
1502 this.#element.selectedItem = item;
1505 // This should never happen, but try and make sure we have at least one
1506 // selected item.
1507 if (!this.#element.selectedItem) {
1508 this.#element.selectedIndex = 0;