tor-browser

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

search.js (48183B)


      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/. */
      4 
      5 /* import-globals-from extensionControlled.js */
      6 /* import-globals-from preferences.js */
      7 
      8 const lazy = {};
      9 
     10 ChromeUtils.defineESModuleGetters(lazy, {
     11  AddonSearchEngine:
     12    "moz-src:///toolkit/components/search/AddonSearchEngine.sys.mjs",
     13  CustomizableUI:
     14    "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs",
     15  QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs",
     16  SearchUIUtils: "moz-src:///browser/components/search/SearchUIUtils.sys.mjs",
     17  SearchUtils: "moz-src:///toolkit/components/search/SearchUtils.sys.mjs",
     18  UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
     19  UserSearchEngine:
     20    "moz-src:///toolkit/components/search/UserSearchEngine.sys.mjs",
     21 });
     22 
     23 Preferences.addAll([
     24  { id: "browser.search.suggest.enabled", type: "bool" },
     25  { id: "browser.urlbar.suggest.searches", type: "bool" },
     26  { id: "browser.search.suggest.enabled.private", type: "bool" },
     27  { id: "browser.urlbar.showSearchSuggestionsFirst", type: "bool" },
     28  { id: "browser.urlbar.showSearchTerms.enabled", type: "bool" },
     29  { id: "browser.urlbar.showSearchTerms.featureGate", type: "bool" },
     30  { id: "browser.search.separatePrivateDefault", type: "bool" },
     31  { id: "browser.search.separatePrivateDefault.ui.enabled", type: "bool" },
     32  { id: "browser.urlbar.suggest.trending", type: "bool" },
     33  { id: "browser.urlbar.trending.featureGate", type: "bool" },
     34  { id: "browser.urlbar.recentsearches.featureGate", type: "bool" },
     35  { id: "browser.urlbar.suggest.recentsearches", type: "bool" },
     36  { id: "browser.urlbar.scotchBonnet.enableOverride", type: "bool" },
     37 
     38  // Suggest Section.
     39  { id: "browser.urlbar.suggest.bookmark", type: "bool" },
     40  { id: "browser.urlbar.suggest.clipboard", type: "bool" },
     41  { id: "browser.urlbar.clipboard.featureGate", type: "bool" },
     42  { id: "browser.urlbar.suggest.history", type: "bool" },
     43  { id: "browser.urlbar.suggest.openpage", type: "bool" },
     44  { id: "browser.urlbar.suggest.topsites", type: "bool" },
     45  { id: "browser.urlbar.suggest.engines", type: "bool" },
     46  { id: "browser.urlbar.quickactions.showPrefs", type: "bool" },
     47  { id: "browser.urlbar.suggest.quickactions", type: "bool" },
     48  { id: "browser.urlbar.quicksuggest.settingsUi", type: "int" },
     49  { id: "browser.urlbar.quicksuggest.enabled", type: "bool" },
     50  { id: "browser.urlbar.suggest.quicksuggest.all", type: "bool" },
     51  { id: "browser.urlbar.suggest.quicksuggest.sponsored", type: "bool" },
     52  { id: "browser.urlbar.quicksuggest.online.enabled", type: "bool" },
     53 ]);
     54 
     55 /**
     56 * Generates the config needed to populate the dropdowns for the user's
     57 * default search engine and separate private default search engine.
     58 *
     59 * @param {object} options
     60 *   Options for creating the config.
     61 * @param {string} options.settingId
     62 *   The id for the particular setting.
     63 * @param {() => Promise<nsISearchEngine>} options.getEngine
     64 *   The method used to get the engine from the Search Service.
     65 * @param {(id: string) => Promise<void>} options.setEngine
     66 *   The method used to set a new engine.
     67 * @returns {PreferencesSettingsConfig}
     68 */
     69 function createSearchEngineConfig({ settingId, getEngine, setEngine }) {
     70  return class extends Preferences.AsyncSetting {
     71    static id = settingId;
     72    ENGINE_MODIFIED = "browser-search-engine-modified";
     73    iconMap = new Map();
     74 
     75    /** @type {{options: PreferencesSettingsConfig[]}} */
     76    defaultGetControlConfig = { options: [] };
     77 
     78    async get() {
     79      let engine = await getEngine();
     80      return engine.id;
     81    }
     82 
     83    async set(id) {
     84      await setEngine(id);
     85    }
     86 
     87    async getControlConfig() {
     88      let engines = await Services.search.getVisibleEngines();
     89      await Promise.allSettled(engines.map(e => this.loadEngineIcon(e)));
     90      return {
     91        options: engines.map(engine => ({
     92          value: engine.id,
     93          iconSrc: this.getEngineIcon(engine),
     94          controlAttrs: {
     95            label: engine.name,
     96          },
     97        })),
     98      };
     99    }
    100 
    101    getEngineIcon(engine) {
    102      return this.iconMap.get(engine.id);
    103    }
    104 
    105    getPlaceholderIcon() {
    106      return window.devicePixelRatio > 1
    107        ? "chrome://browser/skin/search-engine-placeholder@2x.png"
    108        : "chrome://browser/skin/search-engine-placeholder.png";
    109    }
    110 
    111    async loadEngineIcon(engine) {
    112      try {
    113        let iconURL = await engine.getIconURL();
    114        let url = iconURL ?? this.getPlaceholderIcon();
    115        this.iconMap.set(engine.id, url);
    116        return url;
    117      } catch (error) {
    118        console.warn(`Failed to load icon for engine ${engine.name}:`, error);
    119        let placeholderIcon = this.getPlaceholderIcon();
    120        this.iconMap.set(engine.id, placeholderIcon);
    121        return placeholderIcon;
    122      }
    123    }
    124 
    125    setup() {
    126      Services.obs.addObserver(this, this.ENGINE_MODIFIED);
    127      return () => Services.obs.removeObserver(this, this.ENGINE_MODIFIED);
    128    }
    129 
    130    observe(subject, topic, data) {
    131      if (topic == this.ENGINE_MODIFIED) {
    132        let engine = subject.QueryInterface(Ci.nsISearchEngine);
    133 
    134        // Clean up cache for removed engines.
    135        if (data == "engine-removed") {
    136          this.iconMap.delete(engine.id);
    137        }
    138 
    139        // Always emit change for any change that could affect the engine list
    140        // or default.
    141        this.emitChange();
    142      }
    143    }
    144  };
    145 }
    146 
    147 Preferences.addSetting(
    148  createSearchEngineConfig({
    149    settingId: "defaultEngineNormal",
    150    getEngine: () => Services.search.getDefault(),
    151    setEngine: id =>
    152      Services.search.setDefault(
    153        Services.search.getEngineById(id),
    154        Ci.nsISearchService.CHANGE_REASON_USER
    155      ),
    156  })
    157 );
    158 
    159 Preferences.addSetting({
    160  id: "scotchBonnetEnabled",
    161  pref: "browser.urlbar.scotchBonnet.enableOverride",
    162 });
    163 
    164 Preferences.addSetting({
    165  id: "showSearchTermsFeatureGate",
    166  pref: "browser.urlbar.showSearchTerms.featureGate",
    167 });
    168 
    169 Preferences.addSetting({
    170  id: "searchShowSearchTermCheckbox",
    171  pref: "browser.urlbar.showSearchTerms.enabled",
    172  deps: ["scotchBonnetEnabled", "showSearchTermsFeatureGate"],
    173  visible: ({ scotchBonnetEnabled, showSearchTermsFeatureGate }) => {
    174    if (lazy.CustomizableUI.getPlacementOfWidget("search-container")) {
    175      return false;
    176    }
    177    return showSearchTermsFeatureGate.value || scotchBonnetEnabled.value;
    178  },
    179  setup: onChange => {
    180    // Add observer of CustomizableUI as showSearchTerms checkbox should be
    181    // hidden while searchbar is enabled.
    182    let customizableUIListener = {
    183      onWidgetAfterDOMChange: node => {
    184        if (node.id == "search-container") {
    185          onChange();
    186        }
    187      },
    188    };
    189    lazy.CustomizableUI.addListener(customizableUIListener);
    190    return () => lazy.CustomizableUI.removeListener(customizableUIListener);
    191  },
    192 });
    193 
    194 Preferences.addSetting({
    195  id: "separatePrivateDefaultUI",
    196  pref: "browser.search.separatePrivateDefault.ui.enabled",
    197  onUserChange: () => {
    198    gSearchPane._engineStore.notifyRebuildViews();
    199  },
    200 });
    201 
    202 Preferences.addSetting({
    203  id: "browserSeparateDefaultEngine",
    204  pref: "browser.search.separatePrivateDefault",
    205  deps: ["separatePrivateDefaultUI"],
    206  visible: ({ separatePrivateDefaultUI }) => {
    207    return separatePrivateDefaultUI.value;
    208  },
    209  onUserChange: () => {
    210    gSearchPane._engineStore.notifyRebuildViews();
    211  },
    212 });
    213 
    214 Preferences.addSetting(
    215  createSearchEngineConfig({
    216    settingId: "defaultPrivateEngine",
    217    getEngine: () => Services.search.getDefaultPrivate(),
    218    setEngine: id =>
    219      Services.search.setDefaultPrivate(
    220        Services.search.getEngineById(id),
    221        Ci.nsISearchService.CHANGE_REASON_USER
    222      ),
    223  })
    224 );
    225 
    226 Preferences.addSetting({
    227  id: "searchSuggestionsEnabledPref",
    228  pref: "browser.search.suggest.enabled",
    229 });
    230 
    231 Preferences.addSetting({
    232  id: "permanentPBEnabledPref",
    233  pref: "browser.privatebrowsing.autostart",
    234 });
    235 
    236 Preferences.addSetting({
    237  id: "urlbarSuggestionsEnabledPref",
    238  pref: "browser.urlbar.suggest.searches",
    239 });
    240 
    241 Preferences.addSetting({
    242  id: "trendingFeaturegatePref",
    243  pref: "browser.urlbar.trending.featureGate",
    244 });
    245 
    246 // The show search suggestion box behaves differently depending on whether the
    247 // separate search bar is shown. When the separate search bar is shown, it
    248 // controls just the search suggestion preference, and the
    249 // `urlBarSuggestionCheckbox` handles the urlbar suggestions. When the separate
    250 // search bar is not shown, this checkbox toggles both preferences to ensure
    251 // that the urlbar suggestion preference is set correctly, since that will be
    252 // the only bar visible.
    253 Preferences.addSetting({
    254  id: "suggestionsInSearchFieldsCheckbox",
    255  deps: ["searchSuggestionsEnabledPref", "urlbarSuggestionsEnabledPref"],
    256  get(_, deps) {
    257    let searchBarVisible =
    258      !!lazy.CustomizableUI.getPlacementOfWidget("search-container");
    259    return (
    260      deps.searchSuggestionsEnabledPref.value &&
    261      (searchBarVisible || deps.urlbarSuggestionsEnabledPref.value)
    262    );
    263  },
    264  set(newCheckedValue, deps) {
    265    let searchBarVisible =
    266      !!lazy.CustomizableUI.getPlacementOfWidget("search-container");
    267    if (!searchBarVisible) {
    268      deps.urlbarSuggestionsEnabledPref.value = newCheckedValue;
    269    }
    270    deps.searchSuggestionsEnabledPref.value = newCheckedValue;
    271    return newCheckedValue;
    272  },
    273 });
    274 
    275 Preferences.addSetting({
    276  id: "urlBarSuggestionCheckbox",
    277  deps: [
    278    "urlbarSuggestionsEnabledPref",
    279    "suggestionsInSearchFieldsCheckbox",
    280    "searchSuggestionsEnabledPref",
    281    "permanentPBEnabledPref",
    282  ],
    283  get: (_, deps) => {
    284    let searchBarVisible =
    285      !!lazy.CustomizableUI.getPlacementOfWidget("search-container");
    286    if (
    287      deps.suggestionsInSearchFieldsCheckbox.value &&
    288      searchBarVisible &&
    289      deps.urlbarSuggestionsEnabledPref.value
    290    ) {
    291      return true;
    292    }
    293    return false;
    294  },
    295  set: (newCheckedValue, deps, setting) => {
    296    if (setting.disabled) {
    297      deps.urlbarSuggestionsEnabledPref.value = false;
    298      return false;
    299    }
    300 
    301    let searchBarVisible =
    302      !!lazy.CustomizableUI.getPlacementOfWidget("search-container");
    303    if (deps.suggestionsInSearchFieldsCheckbox.value && searchBarVisible) {
    304      deps.urlbarSuggestionsEnabledPref.value = newCheckedValue;
    305    }
    306    return newCheckedValue;
    307  },
    308  setup: onChange => {
    309    // Add observer of CustomizableUI as checkbox should be hidden while
    310    // searchbar is enabled.
    311    let customizableUIListener = {
    312      onWidgetAfterDOMChange: node => {
    313        if (node.id == "search-container") {
    314          onChange();
    315        }
    316      },
    317    };
    318    lazy.CustomizableUI.addListener(customizableUIListener);
    319    return () => lazy.CustomizableUI.removeListener(customizableUIListener);
    320  },
    321  disabled: deps => {
    322    return (
    323      !deps.searchSuggestionsEnabledPref.value ||
    324      deps.permanentPBEnabledPref.value
    325    );
    326  },
    327  visible: () => {
    328    let searchBarVisible =
    329      !!lazy.CustomizableUI.getPlacementOfWidget("search-container");
    330    return searchBarVisible;
    331  },
    332 });
    333 
    334 Preferences.addSetting({
    335  id: "showSearchSuggestionsFirstCheckbox",
    336  pref: "browser.urlbar.showSearchSuggestionsFirst",
    337  deps: [
    338    "suggestionsInSearchFieldsCheckbox",
    339    "urlbarSuggestionsEnabledPref",
    340    "searchSuggestionsEnabledPref",
    341    "permanentPBEnabledPref",
    342  ],
    343  get: (newCheckedValue, deps) => {
    344    if (!deps.searchSuggestionsEnabledPref.value) {
    345      return false;
    346    }
    347    return deps.urlbarSuggestionsEnabledPref.value ? newCheckedValue : false;
    348  },
    349  disabled: deps => {
    350    return (
    351      !deps.suggestionsInSearchFieldsCheckbox.value ||
    352      !deps.urlbarSuggestionsEnabledPref.value ||
    353      deps.permanentPBEnabledPref.value
    354    );
    355  },
    356 });
    357 
    358 Preferences.addSetting({
    359  id: "showSearchSuggestionsPrivateWindowsCheckbox",
    360  pref: "browser.search.suggest.enabled.private",
    361  deps: ["searchSuggestionsEnabledPref"],
    362  disabled: deps => {
    363    return !deps.searchSuggestionsEnabledPref.value;
    364  },
    365 });
    366 
    367 Preferences.addSetting({
    368  id: "showTrendingSuggestionsCheckbox",
    369  pref: "browser.urlbar.suggest.trending",
    370  deps: [
    371    "searchSuggestionsEnabledPref",
    372    "permanentPBEnabledPref",
    373    // Required to dynamically update the disabled state when the default engine is changed.
    374    "defaultEngineNormal",
    375    "trendingFeaturegatePref",
    376  ],
    377  visible: deps => deps.trendingFeaturegatePref.value,
    378  disabled: deps => {
    379    let trendingSupported = Services.search.defaultEngine.supportsResponseType(
    380      lazy.SearchUtils.URL_TYPE.TRENDING_JSON
    381    );
    382    return (
    383      !deps.searchSuggestionsEnabledPref.value ||
    384      deps.permanentPBEnabledPref.value ||
    385      !trendingSupported
    386    );
    387  },
    388 });
    389 
    390 Preferences.addSetting({
    391  id: "urlBarSuggestionPermanentPBMessage",
    392  deps: ["urlBarSuggestionCheckbox", "permanentPBEnabledPref"],
    393  visible: deps => {
    394    return (
    395      deps.urlBarSuggestionCheckbox.visible && deps.permanentPBEnabledPref.value
    396    );
    397  },
    398 });
    399 
    400 Preferences.addSetting({
    401  id: "quickSuggestEnabledPref",
    402  pref: "browser.urlbar.quicksuggest.enabled",
    403 });
    404 
    405 Preferences.addSetting({
    406  id: "quickSuggestSettingsUiPref",
    407  pref: "browser.urlbar.quicksuggest.settingsUi",
    408 });
    409 
    410 Preferences.addSetting({
    411  id: "nimbusListener",
    412  setup(onChange) {
    413    NimbusFeatures.urlbar.onUpdate(onChange);
    414    return () => NimbusFeatures.urlbar.offUpdate(onChange);
    415  },
    416 });
    417 
    418 Preferences.addSetting({
    419  id: "locationBarGroupHeader",
    420  deps: [
    421    "quickSuggestEnabledPref",
    422    "quickSuggestSettingsUiPref",
    423    "nimbusListener",
    424  ],
    425  getControlConfig(config) {
    426    let l10nId =
    427      lazy.UrlbarPrefs.get("quickSuggestEnabled") &&
    428      lazy.UrlbarPrefs.get("quickSuggestSettingsUi") !=
    429        lazy.QuickSuggest.SETTINGS_UI.NONE
    430        ? "addressbar-header-firefox-suggest-2"
    431        : "addressbar-header-1";
    432 
    433    return { ...config, l10nId };
    434  },
    435 });
    436 
    437 Preferences.addSetting({
    438  id: "historySuggestion",
    439  pref: "browser.urlbar.suggest.history",
    440 });
    441 
    442 Preferences.addSetting({
    443  id: "bookmarkSuggestion",
    444  pref: "browser.urlbar.suggest.bookmark",
    445 });
    446 
    447 Preferences.addSetting({
    448  id: "clipboardFeaturegate",
    449  pref: "browser.urlbar.clipboard.featureGate",
    450 });
    451 
    452 Preferences.addSetting({
    453  id: "clipboardSuggestion",
    454  pref: "browser.urlbar.suggest.clipboard",
    455  deps: ["clipboardFeaturegate"],
    456  visible: deps => {
    457    return deps.clipboardFeaturegate.value;
    458  },
    459 });
    460 
    461 Preferences.addSetting({
    462  id: "openpageSuggestion",
    463  pref: "browser.urlbar.suggest.openpage",
    464 });
    465 
    466 Preferences.addSetting({
    467  id: "topSitesSuggestion",
    468  pref: "browser.urlbar.suggest.topsites",
    469 });
    470 
    471 Preferences.addSetting({
    472  id: "enableRecentSearchesFeatureGate",
    473  pref: "browser.urlbar.recentsearches.featureGate",
    474 });
    475 
    476 Preferences.addSetting({
    477  id: "enableRecentSearches",
    478  pref: "browser.urlbar.suggest.recentsearches",
    479  deps: ["enableRecentSearchesFeatureGate"],
    480  visible: deps => {
    481    return deps.enableRecentSearchesFeatureGate.value;
    482  },
    483 });
    484 
    485 Preferences.addSetting({
    486  id: "enginesSuggestion",
    487  pref: "browser.urlbar.suggest.engines",
    488 });
    489 
    490 Preferences.addSetting({
    491  id: "quickActionsShowPrefs",
    492  pref: "browser.urlbar.quickactions.showPrefs",
    493 });
    494 
    495 Preferences.addSetting({
    496  id: "enableQuickActions",
    497  pref: "browser.urlbar.suggest.quickactions",
    498  deps: ["quickActionsShowPrefs", "scotchBonnetEnabled"],
    499  visible: deps => {
    500    return deps.quickActionsShowPrefs.value || deps.scotchBonnetEnabled.value;
    501  },
    502 });
    503 
    504 Preferences.addSetting({
    505  id: "firefoxSuggestAll",
    506  pref: "browser.urlbar.suggest.quicksuggest.all",
    507 });
    508 
    509 Preferences.addSetting({
    510  id: "firefoxSuggestSponsored",
    511  pref: "browser.urlbar.suggest.quicksuggest.sponsored",
    512  deps: ["firefoxSuggestAll"],
    513  disabled: deps => {
    514    return !deps.firefoxSuggestAll.value;
    515  },
    516 });
    517 
    518 Preferences.addSetting({
    519  id: "firefoxSuggestOnlineEnabledToggle",
    520  pref: "browser.urlbar.quicksuggest.online.enabled",
    521  deps: [
    522    "firefoxSuggestAll",
    523    "quickSuggestEnabledPref",
    524    "quickSuggestSettingsUiPref",
    525    "nimbusListener",
    526  ],
    527  visible: () => {
    528    return (
    529      lazy.UrlbarPrefs.get("quickSuggestSettingsUi") ==
    530      lazy.QuickSuggest.SETTINGS_UI.FULL
    531    );
    532  },
    533  disabled: deps => {
    534    return !deps.firefoxSuggestAll.value;
    535  },
    536 });
    537 
    538 Preferences.addSetting(
    539  class extends Preferences.AsyncSetting {
    540    static id = "restoreDismissedSuggestions";
    541    setup() {
    542      Services.obs.addObserver(
    543        this.emitChange,
    544        "quicksuggest-dismissals-changed"
    545      );
    546      return () => {
    547        Services.obs.removeObserver(
    548          this.emitChange,
    549          "quicksuggest-dismissals-changed"
    550        );
    551      };
    552    }
    553    async disabled() {
    554      return !(await lazy.QuickSuggest.canClearDismissedSuggestions());
    555    }
    556    onUserClick() {
    557      lazy.QuickSuggest.clearDismissedSuggestions();
    558    }
    559  }
    560 );
    561 
    562 Preferences.addSetting({
    563  id: "dismissedSuggestionsDescription",
    564 });
    565 
    566 const ENGINE_FLAVOR = "text/x-moz-search-engine";
    567 const SEARCH_TYPE = "default_search";
    568 const SEARCH_KEY = "defaultSearch";
    569 
    570 var gEngineView = null;
    571 
    572 var gSearchPane = {
    573  _engineStore: null,
    574 
    575  init() {
    576    initSettingGroup("defaultEngine");
    577    initSettingGroup("searchSuggestions");
    578    initSettingGroup("firefoxSuggest");
    579    this._engineStore = new EngineStore();
    580    gEngineView = new EngineView(this._engineStore);
    581 
    582    this._engineStore.init().catch(console.error);
    583 
    584    if (
    585      Services.policies &&
    586      !Services.policies.isAllowed("installSearchEngine")
    587    ) {
    588      document.getElementById("addEnginesBox").hidden = true;
    589    } else {
    590      let addEnginesLink = document.getElementById("addEngines");
    591      addEnginesLink.setAttribute("href", lazy.SearchUIUtils.searchEnginesURL);
    592    }
    593 
    594    window.addEventListener("command", this);
    595 
    596    Services.obs.addObserver(this, "browser-search-engine-modified");
    597    Services.obs.addObserver(this, "intl:app-locales-changed");
    598    window.addEventListener("unload", () => {
    599      Services.obs.removeObserver(this, "browser-search-engine-modified");
    600      Services.obs.removeObserver(this, "intl:app-locales-changed");
    601    });
    602  },
    603 
    604  // ADDRESS BAR
    605  handleEvent(aEvent) {
    606    if (aEvent.type != "command") {
    607      return;
    608    }
    609    switch (aEvent.target.id) {
    610      case "":
    611        if (aEvent.target.parentNode && aEvent.target.parentNode.parentNode) {
    612          if (aEvent.target.parentNode.parentNode.id == "defaultEngine") {
    613            gSearchPane.setDefaultEngine();
    614          } else if (
    615            aEvent.target.parentNode.parentNode.id == "defaultPrivateEngine"
    616          ) {
    617            gSearchPane.setDefaultPrivateEngine();
    618          }
    619        }
    620        break;
    621      default:
    622        gEngineView.handleEvent(aEvent);
    623    }
    624  },
    625 
    626  /**
    627   * Handle when the app locale is changed.
    628   */
    629  async appLocalesChanged() {
    630    await document.l10n.ready;
    631    await gEngineView.loadL10nNames();
    632  },
    633 
    634  /**
    635   * nsIObserver implementation.
    636   */
    637  observe(subject, topic, data) {
    638    switch (topic) {
    639      case "intl:app-locales-changed": {
    640        this.appLocalesChanged();
    641        break;
    642      }
    643      case "browser-search-engine-modified": {
    644        let engine = subject.QueryInterface(Ci.nsISearchEngine);
    645        switch (data) {
    646          case "engine-default": {
    647            // Pass through to the engine store to handle updates.
    648            this._engineStore.browserSearchEngineModified(engine, data);
    649            break;
    650          }
    651          default:
    652            this._engineStore.browserSearchEngineModified(engine, data);
    653        }
    654        break;
    655      }
    656    }
    657  },
    658 
    659  showRestoreDefaults(aEnable) {
    660    document.getElementById("restoreDefaultSearchEngines").disabled = !aEnable;
    661  },
    662 
    663  async setDefaultEngine() {
    664    await Services.search.setDefault(
    665      document.getElementById("defaultEngine").selectedItem.engine,
    666      Ci.nsISearchService.CHANGE_REASON_USER
    667    );
    668    if (ExtensionSettingsStore.getSetting(SEARCH_TYPE, SEARCH_KEY) !== null) {
    669      ExtensionSettingsStore.select(
    670        ExtensionSettingsStore.SETTING_USER_SET,
    671        SEARCH_TYPE,
    672        SEARCH_KEY
    673      );
    674    }
    675  },
    676 
    677  async setDefaultPrivateEngine() {
    678    await Services.search.setDefaultPrivate(
    679      document.getElementById("defaultPrivateEngine").selectedItem.engine,
    680      Ci.nsISearchService.CHANGE_REASON_USER
    681    );
    682  },
    683 };
    684 
    685 /**
    686 * Keeps track of the search engine objects and notifies the views for updates.
    687 */
    688 class EngineStore {
    689  /**
    690   * A list of engines that are currently visible in the UI.
    691   *
    692   * @type {object[]}
    693   */
    694  engines = [];
    695 
    696  /**
    697   * A list of listeners to be notified when the engine list changes.
    698   *
    699   * @type {object[]}
    700   */
    701  #listeners = [];
    702 
    703  async init() {
    704    let engines = await Services.search.getEngines();
    705 
    706    let visibleEngines = engines.filter(e => !e.hidden);
    707    for (let engine of visibleEngines) {
    708      this.addEngine(engine);
    709    }
    710    this.notifyRowCountChanged(0, visibleEngines.length);
    711 
    712    gSearchPane.showRestoreDefaults(
    713      engines.some(e => e.isAppProvided && e.hidden)
    714    );
    715  }
    716 
    717  /**
    718   * Adds a listener to be notified when the engine list changes.
    719   *
    720   * @param {object} aListener
    721   */
    722  addListener(aListener) {
    723    this.#listeners.push(aListener);
    724  }
    725 
    726  /**
    727   * Notifies all listeners that the engine list has changed and they should
    728   * rebuild.
    729   */
    730  notifyRebuildViews() {
    731    for (let listener of this.#listeners) {
    732      try {
    733        listener.rebuild(this.engines);
    734      } catch (ex) {
    735        console.error("Error notifying EngineStore listener", ex);
    736      }
    737    }
    738  }
    739 
    740  /**
    741   * Notifies all listeners that the number of engines in the list has changed.
    742   *
    743   * @param {number} index
    744   * @param {number} count
    745   */
    746  notifyRowCountChanged(index, count) {
    747    for (let listener of this.#listeners) {
    748      listener.rowCountChanged(index, count, this.engines);
    749    }
    750  }
    751 
    752  /**
    753   * Notifies all listeners that the default engine has changed.
    754   *
    755   * @param {string} type
    756   * @param {object} engine
    757   */
    758  notifyDefaultEngineChanged(type, engine) {
    759    for (let listener of this.#listeners) {
    760      if ("defaultEngineChanged" in listener) {
    761        listener.defaultEngineChanged(type, engine, this.engines);
    762      }
    763    }
    764  }
    765 
    766  notifyEngineIconUpdated(engine) {
    767    // Check the engine is still in the list.
    768    let index = this._getIndexForEngine(engine);
    769    if (index != -1) {
    770      for (let listener of this.#listeners) {
    771        listener.engineIconUpdated(index, this.engines);
    772      }
    773    }
    774  }
    775 
    776  _getIndexForEngine(aEngine) {
    777    return this.engines.indexOf(aEngine);
    778  }
    779 
    780  _getEngineByName(aName) {
    781    return this.engines.find(engine => engine.name == aName);
    782  }
    783 
    784  /**
    785   * Converts an nsISearchEngine object into an Engine Store
    786   * search engine object.
    787   *
    788   * @param {nsISearchEngine} aEngine
    789   *   The search engine to convert.
    790   * @returns {object}
    791   *   The EngineStore search engine object.
    792   */
    793  _cloneEngine(aEngine) {
    794    var clonedObj = {
    795      iconURL: null,
    796    };
    797    for (let i of ["id", "name", "alias", "hidden", "isAppProvided"]) {
    798      clonedObj[i] = aEngine[i];
    799    }
    800    clonedObj.isAddonEngine =
    801      aEngine.wrappedJSObject instanceof lazy.AddonSearchEngine;
    802    clonedObj.isUserEngine =
    803      aEngine.wrappedJSObject instanceof lazy.UserSearchEngine;
    804    clonedObj.originalEngine = aEngine;
    805 
    806    // Trigger getting the iconURL for this engine.
    807    aEngine.getIconURL().then(iconURL => {
    808      if (iconURL) {
    809        clonedObj.iconURL = iconURL;
    810      } else if (window.devicePixelRatio > 1) {
    811        clonedObj.iconURL =
    812          "chrome://browser/skin/search-engine-placeholder@2x.png";
    813      } else {
    814        clonedObj.iconURL =
    815          "chrome://browser/skin/search-engine-placeholder.png";
    816      }
    817 
    818      this.notifyEngineIconUpdated(clonedObj);
    819    });
    820 
    821    return clonedObj;
    822  }
    823 
    824  // Callback for Array's some(). A thisObj must be passed to some()
    825  _isSameEngine(aEngineClone) {
    826    return aEngineClone.originalEngine.id == this.originalEngine.id;
    827  }
    828 
    829  addEngine(aEngine) {
    830    this.engines.push(this._cloneEngine(aEngine));
    831  }
    832 
    833  updateEngine(newEngine) {
    834    let engineToUpdate = this.engines.findIndex(
    835      e => e.originalEngine.id == newEngine.id
    836    );
    837    if (engineToUpdate != -1) {
    838      this.engines[engineToUpdate] = this._cloneEngine(newEngine);
    839    }
    840  }
    841 
    842  moveEngine(aEngine, aNewIndex) {
    843    if (aNewIndex < 0 || aNewIndex > this.engines.length - 1) {
    844      throw new Error("ES_moveEngine: invalid aNewIndex!");
    845    }
    846    var index = this._getIndexForEngine(aEngine);
    847    if (index == -1) {
    848      throw new Error("ES_moveEngine: invalid engine?");
    849    }
    850 
    851    if (index == aNewIndex) {
    852      return Promise.resolve();
    853    } // nothing to do
    854 
    855    // Move the engine in our internal store
    856    var removedEngine = this.engines.splice(index, 1)[0];
    857    this.engines.splice(aNewIndex, 0, removedEngine);
    858 
    859    return Services.search.moveEngine(aEngine.originalEngine, aNewIndex);
    860  }
    861 
    862  /**
    863   * Called when a search engine is removed.
    864   *
    865   * @param {nsISearchEngine} aEngine
    866   *   The Engine being removed. Note that this is an nsISearchEngine object.
    867   */
    868  removeEngine(aEngine) {
    869    if (this.engines.length == 1) {
    870      throw new Error("Cannot remove last engine!");
    871    }
    872 
    873    let engineId = aEngine.id;
    874    let index = this.engines.findIndex(element => element.id == engineId);
    875 
    876    if (index == -1) {
    877      throw new Error("invalid engine?");
    878    }
    879 
    880    this.engines.splice(index, 1)[0];
    881 
    882    if (aEngine.isAppProvided) {
    883      gSearchPane.showRestoreDefaults(true);
    884    }
    885 
    886    this.notifyRowCountChanged(index, -1);
    887 
    888    document.getElementById("engineList").focus();
    889  }
    890 
    891  /**
    892   * Update the default engine UI and engine tree view as appropriate when engine changes
    893   * or locale changes occur.
    894   *
    895   * @param {nsISearchEngine} engine
    896   * @param {string} data
    897   */
    898  browserSearchEngineModified(engine, data) {
    899    engine.QueryInterface(Ci.nsISearchEngine);
    900    switch (data) {
    901      case "engine-added":
    902        this.addEngine(engine);
    903        this.notifyRowCountChanged(gEngineView.lastEngineIndex, 1);
    904        break;
    905      case "engine-changed":
    906      case "engine-icon-changed":
    907        this.updateEngine(engine);
    908        this.notifyRebuildViews();
    909        break;
    910      case "engine-removed":
    911        this.removeEngine(engine);
    912        break;
    913      case "engine-default":
    914        this.notifyDefaultEngineChanged("normal", engine);
    915        break;
    916      case "engine-default-private":
    917        this.notifyDefaultEngineChanged("private", engine);
    918        break;
    919    }
    920  }
    921 
    922  async restoreDefaultEngines() {
    923    var added = 0;
    924    // _cloneEngine is necessary here because all functions in
    925    // this file work on EngineStore search engine objects.
    926    let appProvidedEngines = (
    927      await Services.search.getAppProvidedEngines()
    928    ).map(this._cloneEngine, this);
    929 
    930    for (var i = 0; i < appProvidedEngines.length; ++i) {
    931      var e = appProvidedEngines[i];
    932 
    933      // If the engine is already in the list, just move it.
    934      if (this.engines.some(this._isSameEngine, e)) {
    935        await this.moveEngine(this._getEngineByName(e.name), i);
    936      } else {
    937        // Otherwise, add it back to our internal store
    938 
    939        // The search service removes the alias when an engine is hidden,
    940        // so clear any alias we may have cached before unhiding the engine.
    941        e.alias = "";
    942 
    943        this.engines.splice(i, 0, e);
    944        let engine = e.originalEngine;
    945        engine.hidden = false;
    946        await Services.search.moveEngine(engine, i);
    947        added++;
    948      }
    949    }
    950 
    951    // We can't do this as part of the loop above because the indices are
    952    // used for moving engines.
    953    let policyRemovedEngineNames =
    954      Services.policies.getActivePolicies()?.SearchEngines?.Remove || [];
    955    for (let engineName of policyRemovedEngineNames) {
    956      let engine = Services.search.getEngineByName(engineName);
    957      if (engine) {
    958        try {
    959          await Services.search.removeEngine(
    960            engine,
    961            Ci.nsISearchService.CHANGE_REASON_ENTERPRISE
    962          );
    963        } catch (ex) {
    964          // Engine might not exist
    965        }
    966      }
    967    }
    968 
    969    Services.search.resetToAppDefaultEngine();
    970    gSearchPane.showRestoreDefaults(false);
    971    this.notifyRebuildViews();
    972    return added;
    973  }
    974 
    975  changeEngine(aEngine, aProp, aNewValue) {
    976    var index = this._getIndexForEngine(aEngine);
    977    if (index == -1) {
    978      throw new Error("invalid engine?");
    979    }
    980 
    981    this.engines[index][aProp] = aNewValue;
    982    aEngine.originalEngine[aProp] = aNewValue;
    983  }
    984 }
    985 
    986 /**
    987 * Manages the view of the Search Shortcuts tree on the search pane of preferences.
    988 */
    989 class EngineView {
    990  _engineStore;
    991  _engineList = null;
    992  tree = null;
    993 
    994  /**
    995   * @param {EngineStore} aEngineStore
    996   */
    997  constructor(aEngineStore) {
    998    this._engineStore = aEngineStore;
    999    this._engineList = document.getElementById("engineList");
   1000    this._engineList.view = this;
   1001 
   1002    lazy.UrlbarPrefs.addObserver(this);
   1003    aEngineStore.addListener(this);
   1004 
   1005    this.loadL10nNames();
   1006    this.#addListeners();
   1007  }
   1008 
   1009  async loadL10nNames() {
   1010    // This maps local shortcut sources to their l10n names.  The names are needed
   1011    // by getCellText.  Getting the names is async but getCellText is not, so we
   1012    // cache them here to retrieve them syncronously in getCellText.
   1013    this._localShortcutL10nNames = new Map();
   1014 
   1015    let getIDs = (suffix = "") =>
   1016      UrlbarUtils.LOCAL_SEARCH_MODES.map(mode => {
   1017        let name = UrlbarUtils.getResultSourceName(mode.source);
   1018        return { id: `urlbar-search-mode-${name}${suffix}` };
   1019      });
   1020 
   1021    try {
   1022      let localizedIDs = getIDs();
   1023      let englishIDs = getIDs("-en");
   1024 
   1025      let englishSearchStrings = new Localization([
   1026        "preview/enUS-searchFeatures.ftl",
   1027      ]);
   1028      let localizedNames = await document.l10n.formatValues(localizedIDs);
   1029      let englishNames = await englishSearchStrings.formatValues(englishIDs);
   1030 
   1031      UrlbarUtils.LOCAL_SEARCH_MODES.forEach(({ source }, index) => {
   1032        let localizedName = localizedNames[index];
   1033        let englishName = englishNames[index];
   1034 
   1035        // Add only the English name if localized and English are the same
   1036        let names =
   1037          localizedName === englishName
   1038            ? [englishName]
   1039            : [localizedName, englishName];
   1040 
   1041        this._localShortcutL10nNames.set(source, names);
   1042 
   1043        // Invalidate the tree now that we have the names in case getCellText was
   1044        // called before name retrieval finished.
   1045        this.invalidate();
   1046      });
   1047    } catch (ex) {
   1048      console.error("Error loading l10n names", ex);
   1049    }
   1050  }
   1051 
   1052  #addListeners() {
   1053    this._engineList.addEventListener("click", this);
   1054    this._engineList.addEventListener("dragstart", this);
   1055    this._engineList.addEventListener("keypress", this);
   1056    this._engineList.addEventListener("select", this);
   1057    this._engineList.addEventListener("dblclick", this);
   1058  }
   1059 
   1060  get lastEngineIndex() {
   1061    return this._engineStore.engines.length - 1;
   1062  }
   1063 
   1064  get selectedIndex() {
   1065    var seln = this.selection;
   1066    if (seln.getRangeCount() > 0) {
   1067      var min = {};
   1068      seln.getRangeAt(0, min, {});
   1069      return min.value;
   1070    }
   1071    return -1;
   1072  }
   1073 
   1074  get selectedEngine() {
   1075    return this._engineStore.engines[this.selectedIndex];
   1076  }
   1077 
   1078  // Helpers
   1079  rebuild() {
   1080    this.invalidate();
   1081  }
   1082 
   1083  rowCountChanged(index, count) {
   1084    if (!this.tree) {
   1085      return;
   1086    }
   1087    this.tree.rowCountChanged(index, count);
   1088 
   1089    // If we're removing elements, ensure that we still have a selection.
   1090    if (count < 0) {
   1091      this.selection.select(Math.min(index, this.rowCount - 1));
   1092      this.ensureRowIsVisible(this.currentIndex);
   1093    }
   1094  }
   1095 
   1096  engineIconUpdated(index) {
   1097    this.tree?.invalidateCell(
   1098      index,
   1099      this.tree.columns.getNamedColumn("engineName")
   1100    );
   1101  }
   1102 
   1103  invalidate() {
   1104    this.tree?.invalidate();
   1105  }
   1106 
   1107  ensureRowIsVisible(index) {
   1108    this.tree.ensureRowIsVisible(index);
   1109  }
   1110 
   1111  getSourceIndexFromDrag(dataTransfer) {
   1112    return parseInt(dataTransfer.getData(ENGINE_FLAVOR));
   1113  }
   1114 
   1115  isCheckBox(index, column) {
   1116    return column.id == "engineShown";
   1117  }
   1118 
   1119  isEngineSelectedAndRemovable() {
   1120    let defaultEngine = Services.search.defaultEngine;
   1121    let defaultPrivateEngine = Services.search.defaultPrivateEngine;
   1122    // We don't allow the last remaining engine to be removed, thus the
   1123    // `this.lastEngineIndex != 0` check.
   1124    // We don't allow the default engine to be removed.
   1125    return (
   1126      this.selectedIndex != -1 &&
   1127      this.lastEngineIndex != 0 &&
   1128      !this._getLocalShortcut(this.selectedIndex) &&
   1129      this.selectedEngine.name != defaultEngine.name &&
   1130      this.selectedEngine.name != defaultPrivateEngine.name
   1131    );
   1132  }
   1133 
   1134  /**
   1135   * Removes a search engine from the search service.
   1136   *
   1137   * Application provided engines are removed without confirmation since they
   1138   * can easily be restored. Addon engines are not removed (see comment).
   1139   * For other engine types, the user is prompted for confirmation.
   1140   *
   1141   * @param {object} engine
   1142   *   The search engine object from EngineStore to remove.
   1143   */
   1144  async promptAndRemoveEngine(engine) {
   1145    if (engine.isAppProvided) {
   1146      Services.search.removeEngine(
   1147        this.selectedEngine.originalEngine,
   1148        Ci.nsISearchService.CHANGE_REASON_USER
   1149      );
   1150      return;
   1151    }
   1152 
   1153    if (engine.isAddonEngine) {
   1154      // Addon engines will re-appear after restarting, see Bug 1546652.
   1155      // This should ideally prompt the user if they want to remove the addon.
   1156      let msg = await document.l10n.formatValue("remove-addon-engine-alert");
   1157      alert(msg);
   1158      return;
   1159    }
   1160 
   1161    let [body, removeLabel] = await document.l10n.formatValues([
   1162      "remove-engine-confirmation",
   1163      "remove-engine-remove",
   1164    ]);
   1165 
   1166    let button = Services.prompt.confirmExBC(
   1167      window.browsingContext,
   1168      Services.prompt.MODAL_TYPE_CONTENT,
   1169      null,
   1170      body,
   1171      (Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0) |
   1172        (Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1),
   1173      removeLabel,
   1174      null,
   1175      null,
   1176      null,
   1177      {}
   1178    );
   1179 
   1180    // Button 0 is the remove button.
   1181    if (button == 0) {
   1182      Services.search.removeEngine(
   1183        this.selectedEngine.originalEngine,
   1184        Ci.nsISearchService.CHANGE_REASON_USER
   1185      );
   1186    }
   1187  }
   1188 
   1189  /**
   1190   * Returns the local shortcut corresponding to a tree row, or null if the row
   1191   * is not a local shortcut.
   1192   *
   1193   * @param {number} index
   1194   *   The tree row index.
   1195   * @returns {object}
   1196   *   The local shortcut object or null if the row is not a local shortcut.
   1197   */
   1198  _getLocalShortcut(index) {
   1199    let engineCount = this._engineStore.engines.length;
   1200    if (index < engineCount) {
   1201      return null;
   1202    }
   1203    return UrlbarUtils.LOCAL_SEARCH_MODES[index - engineCount];
   1204  }
   1205 
   1206  /**
   1207   * Called by UrlbarPrefs when a urlbar pref changes.
   1208   *
   1209   * @param {string} pref
   1210   *   The name of the pref relative to the browser.urlbar branch.
   1211   */
   1212  onPrefChanged(pref) {
   1213    // If one of the local shortcut prefs was toggled, toggle its row's
   1214    // checkbox.
   1215    let parts = pref.split(".");
   1216    if (parts[0] == "shortcuts" && parts[1] && parts.length == 2) {
   1217      this.invalidate();
   1218    }
   1219  }
   1220 
   1221  handleEvent(aEvent) {
   1222    switch (aEvent.type) {
   1223      case "dblclick":
   1224        if (aEvent.target.id == "engineChildren") {
   1225          let cell = aEvent.target.parentNode.getCellAt(
   1226            aEvent.clientX,
   1227            aEvent.clientY
   1228          );
   1229          if (cell.col?.id == "engineKeyword") {
   1230            this.#startEditingAlias(this.selectedIndex);
   1231          }
   1232        }
   1233        break;
   1234      case "click":
   1235        if (
   1236          aEvent.target.id != "engineChildren" &&
   1237          !aEvent.target.classList.contains("searchEngineAction")
   1238        ) {
   1239          // We don't want to toggle off selection while editing keyword
   1240          // so proceed only when the input field is hidden.
   1241          // We need to check that engineList.view is defined here
   1242          // because the "click" event listener is on <window> and the
   1243          // view might have been destroyed if the pane has been navigated
   1244          // away from.
   1245          if (this._engineList.inputField.hidden && this._engineList.view) {
   1246            let selection = this._engineList.view.selection;
   1247            if (selection?.count > 0) {
   1248              selection.toggleSelect(selection.currentIndex);
   1249            }
   1250            this._engineList.blur();
   1251          }
   1252        }
   1253        break;
   1254      case "command":
   1255        switch (aEvent.target.id) {
   1256          case "restoreDefaultSearchEngines":
   1257            this.#onRestoreDefaults();
   1258            break;
   1259          case "removeEngineButton":
   1260            if (this.isEngineSelectedAndRemovable()) {
   1261              this.promptAndRemoveEngine(this.selectedEngine);
   1262            }
   1263            break;
   1264          case "editEngineButton":
   1265            if (this.selectedEngine.isUserEngine) {
   1266              let engine = this.selectedEngine.originalEngine.wrappedJSObject;
   1267              gSubDialog.open(
   1268                "chrome://browser/content/search/addEngine.xhtml",
   1269                { features: "resizable=no, modal=yes" },
   1270                { engine, mode: "EDIT" }
   1271              );
   1272            }
   1273            break;
   1274          case "addEngineButton":
   1275            gSubDialog.open(
   1276              "chrome://browser/content/search/addEngine.xhtml",
   1277              { features: "resizable=no, modal=yes" },
   1278              { mode: "NEW" }
   1279            );
   1280            break;
   1281        }
   1282        break;
   1283      case "dragstart":
   1284        if (aEvent.target.id == "engineChildren") {
   1285          this.#onDragEngineStart(aEvent);
   1286        }
   1287        break;
   1288      case "keypress":
   1289        if (aEvent.target.id == "engineList") {
   1290          this.#onTreeKeyPress(aEvent);
   1291        }
   1292        break;
   1293      case "select":
   1294        if (aEvent.target.id == "engineList") {
   1295          this.#onTreeSelect();
   1296        }
   1297        break;
   1298    }
   1299  }
   1300 
   1301  /**
   1302   * Called when the restore default engines button is clicked to reset the
   1303   * list of engines to their defaults.
   1304   */
   1305  async #onRestoreDefaults() {
   1306    let num = await this._engineStore.restoreDefaultEngines();
   1307    this.rowCountChanged(0, num);
   1308  }
   1309 
   1310  #onDragEngineStart(event) {
   1311    let selectedIndex = this.selectedIndex;
   1312 
   1313    // Local shortcut rows can't be dragged or re-ordered.
   1314    if (this._getLocalShortcut(selectedIndex)) {
   1315      event.preventDefault();
   1316      return;
   1317    }
   1318 
   1319    let tree = document.getElementById("engineList");
   1320    let cell = tree.getCellAt(event.clientX, event.clientY);
   1321    if (selectedIndex >= 0 && !this.isCheckBox(cell.row, cell.col)) {
   1322      event.dataTransfer.setData(ENGINE_FLAVOR, selectedIndex.toString());
   1323      event.dataTransfer.effectAllowed = "move";
   1324    }
   1325  }
   1326 
   1327  #onTreeSelect() {
   1328    document.getElementById("removeEngineButton").disabled =
   1329      !this.isEngineSelectedAndRemovable();
   1330    document.getElementById("editEngineButton").disabled =
   1331      !this.selectedEngine?.isUserEngine;
   1332  }
   1333 
   1334  #onTreeKeyPress(aEvent) {
   1335    let index = this.selectedIndex;
   1336    let tree = document.getElementById("engineList");
   1337    if (tree.hasAttribute("editing")) {
   1338      return;
   1339    }
   1340 
   1341    if (aEvent.charCode == KeyEvent.DOM_VK_SPACE) {
   1342      // Space toggles the checkbox.
   1343      let newValue = !this.getCellValue(
   1344        index,
   1345        tree.columns.getNamedColumn("engineShown")
   1346      );
   1347      this.setCellValue(
   1348        index,
   1349        tree.columns.getFirstColumn(),
   1350        newValue.toString()
   1351      );
   1352      // Prevent page from scrolling on the space key.
   1353      aEvent.preventDefault();
   1354    } else {
   1355      let isMac = Services.appinfo.OS == "Darwin";
   1356      if (
   1357        (isMac && aEvent.keyCode == KeyEvent.DOM_VK_RETURN) ||
   1358        (!isMac && aEvent.keyCode == KeyEvent.DOM_VK_F2)
   1359      ) {
   1360        this.#startEditingAlias(index);
   1361      } else if (
   1362        aEvent.keyCode == KeyEvent.DOM_VK_DELETE ||
   1363        (isMac &&
   1364          aEvent.shiftKey &&
   1365          aEvent.keyCode == KeyEvent.DOM_VK_BACK_SPACE)
   1366      ) {
   1367        // Delete and Shift+Backspace (Mac) removes selected engine.
   1368        if (this.isEngineSelectedAndRemovable()) {
   1369          this.promptAndRemoveEngine(this.selectedEngine);
   1370        }
   1371      }
   1372    }
   1373  }
   1374 
   1375  /**
   1376   * Triggers editing of an alias in the tree.
   1377   *
   1378   * @param {number} index
   1379   */
   1380  #startEditingAlias(index) {
   1381    // Local shortcut aliases can't be edited.
   1382    if (this._getLocalShortcut(index)) {
   1383      return;
   1384    }
   1385 
   1386    let engine = this._engineStore.engines[index];
   1387    this.tree.startEditing(index, this.tree.columns.getLastColumn());
   1388    this.tree.inputField.value = engine.alias || "";
   1389    this.tree.inputField.select();
   1390  }
   1391 
   1392  /**
   1393   * Triggers editing of an engine name in the tree.
   1394   *
   1395   * @param {number} index
   1396   */
   1397  #startEditingName(index) {
   1398    let engine = this._engineStore.engines[index];
   1399    if (!engine.isUserEngine) {
   1400      return;
   1401    }
   1402 
   1403    this.tree.startEditing(
   1404      index,
   1405      this.tree.columns.getNamedColumn("engineName")
   1406    );
   1407    this.tree.inputField.value = engine.name;
   1408    this.tree.inputField.select();
   1409  }
   1410 
   1411  // nsITreeView
   1412  get rowCount() {
   1413    let localModes = UrlbarUtils.LOCAL_SEARCH_MODES;
   1414    if (!lazy.UrlbarPrefs.get("scotchBonnet.enableOverride")) {
   1415      localModes = localModes.filter(
   1416        mode => mode.source != UrlbarUtils.RESULT_SOURCE.ACTIONS
   1417      );
   1418    }
   1419    return this._engineStore.engines.length + localModes.length;
   1420  }
   1421 
   1422  getImageSrc(index, column) {
   1423    if (column.id == "engineName") {
   1424      let shortcut = this._getLocalShortcut(index);
   1425      if (shortcut) {
   1426        return shortcut.icon;
   1427      }
   1428 
   1429      return this._engineStore.engines[index].iconURL;
   1430    }
   1431 
   1432    return "";
   1433  }
   1434 
   1435  getCellText(index, column) {
   1436    if (column.id == "engineName") {
   1437      let shortcut = this._getLocalShortcut(index);
   1438      if (shortcut) {
   1439        return this._localShortcutL10nNames.get(shortcut.source)[0] || "";
   1440      }
   1441      return this._engineStore.engines[index].name;
   1442    } else if (column.id == "engineKeyword") {
   1443      let shortcut = this._getLocalShortcut(index);
   1444      if (shortcut) {
   1445        if (
   1446          lazy.UrlbarPrefs.getScotchBonnetPref(
   1447            "searchRestrictKeywords.featureGate"
   1448          )
   1449        ) {
   1450          let keywords = this._localShortcutL10nNames
   1451            .get(shortcut.source)
   1452            .map(keyword => `@${keyword.toLowerCase()}`)
   1453            .join(", ");
   1454 
   1455          return `${keywords}, ${shortcut.restrict}`;
   1456        }
   1457 
   1458        return shortcut.restrict;
   1459      }
   1460      return this._engineStore.engines[index].originalEngine.aliases.join(", ");
   1461    }
   1462    return "";
   1463  }
   1464 
   1465  setTree(tree) {
   1466    this.tree = tree;
   1467  }
   1468 
   1469  canDrop(targetIndex, orientation, dataTransfer) {
   1470    var sourceIndex = this.getSourceIndexFromDrag(dataTransfer);
   1471    return (
   1472      sourceIndex != -1 &&
   1473      sourceIndex != targetIndex &&
   1474      sourceIndex != targetIndex + orientation &&
   1475      // Local shortcut rows can't be dragged or dropped on.
   1476      targetIndex < this._engineStore.engines.length
   1477    );
   1478  }
   1479 
   1480  async drop(dropIndex, orientation, dataTransfer) {
   1481    // Local shortcut rows can't be dragged or dropped on.  This can sometimes
   1482    // be reached even though canDrop returns false for these rows.
   1483    if (this._engineStore.engines.length <= dropIndex) {
   1484      return;
   1485    }
   1486 
   1487    var sourceIndex = this.getSourceIndexFromDrag(dataTransfer);
   1488    var sourceEngine = this._engineStore.engines[sourceIndex];
   1489 
   1490    const nsITreeView = Ci.nsITreeView;
   1491    if (dropIndex > sourceIndex) {
   1492      if (orientation == nsITreeView.DROP_BEFORE) {
   1493        dropIndex--;
   1494      }
   1495    } else if (orientation == nsITreeView.DROP_AFTER) {
   1496      dropIndex++;
   1497    }
   1498 
   1499    await this._engineStore.moveEngine(sourceEngine, dropIndex);
   1500    gSearchPane.showRestoreDefaults(true);
   1501 
   1502    // Redraw, and adjust selection
   1503    this.invalidate();
   1504    this.selection.select(dropIndex);
   1505  }
   1506 
   1507  selection = null;
   1508  getRowProperties() {
   1509    return "";
   1510  }
   1511  getCellProperties(index, column) {
   1512    if (column.id == "engineName") {
   1513      // For local shortcut rows, return the result source name so we can style
   1514      // the icons in CSS.
   1515      let shortcut = this._getLocalShortcut(index);
   1516      if (shortcut) {
   1517        return UrlbarUtils.getResultSourceName(shortcut.source);
   1518      }
   1519    }
   1520    return "";
   1521  }
   1522  getColumnProperties() {
   1523    return "";
   1524  }
   1525  isContainer() {
   1526    return false;
   1527  }
   1528  isContainerOpen() {
   1529    return false;
   1530  }
   1531  isContainerEmpty() {
   1532    return false;
   1533  }
   1534  isSeparator() {
   1535    return false;
   1536  }
   1537  isSorted() {
   1538    return false;
   1539  }
   1540  getParentIndex() {
   1541    return -1;
   1542  }
   1543  hasNextSibling() {
   1544    return false;
   1545  }
   1546  getLevel() {
   1547    return 0;
   1548  }
   1549  getCellValue(index, column) {
   1550    if (column.id == "engineShown") {
   1551      let shortcut = this._getLocalShortcut(index);
   1552      if (shortcut) {
   1553        return lazy.UrlbarPrefs.get(shortcut.pref);
   1554      }
   1555      return !this._engineStore.engines[index].originalEngine.hideOneOffButton;
   1556    }
   1557    return undefined;
   1558  }
   1559  toggleOpenState() {}
   1560  cycleHeader() {}
   1561  selectionChanged() {}
   1562  cycleCell() {}
   1563  isEditable(index, column) {
   1564    return (
   1565      column.id == "engineShown" ||
   1566      (column.id == "engineKeyword" && !this._getLocalShortcut(index)) ||
   1567      (column.id == "engineName" &&
   1568        this._engineStore.engines[index].isUserEngine)
   1569    );
   1570  }
   1571  setCellValue(index, column, value) {
   1572    if (column.id == "engineShown") {
   1573      let shortcut = this._getLocalShortcut(index);
   1574      if (shortcut) {
   1575        lazy.UrlbarPrefs.set(shortcut.pref, value == "true");
   1576        this.invalidate();
   1577        return;
   1578      }
   1579      this._engineStore.engines[index].originalEngine.hideOneOffButton =
   1580        value != "true";
   1581      this.invalidate();
   1582    }
   1583  }
   1584  async setCellText(index, column, value) {
   1585    let engine = this._engineStore.engines[index];
   1586    if (column.id == "engineKeyword") {
   1587      let valid = await this.#changeKeyword(engine, value);
   1588      if (!valid) {
   1589        this.#startEditingAlias(index);
   1590      }
   1591    } else if (column.id == "engineName" && engine.isUserEngine) {
   1592      let valid = await this.#changeName(engine, value);
   1593      if (!valid) {
   1594        this.#startEditingName(index);
   1595      }
   1596    }
   1597  }
   1598 
   1599  /**
   1600   * Handles changing the keyword for an engine. This will check for potentially
   1601   * duplicate keywords and prompt the user if necessary.
   1602   *
   1603   * @param {object} aEngine
   1604   *   The engine to change.
   1605   * @param {string} aNewKeyword
   1606   *   The new keyword.
   1607   * @returns {Promise<boolean>}
   1608   *   Resolves to true if the keyword was changed.
   1609   */
   1610  async #changeKeyword(aEngine, aNewKeyword) {
   1611    let keyword = aNewKeyword.trim();
   1612    if (keyword) {
   1613      let isBookmarkDuplicate = !!(await PlacesUtils.keywords.fetch(keyword));
   1614 
   1615      let dupEngine = await Services.search.getEngineByAlias(keyword);
   1616      let isEngineDuplicate = dupEngine !== null && dupEngine.id != aEngine.id;
   1617 
   1618      // Notify the user if they have chosen an existing engine/bookmark keyword
   1619      if (isEngineDuplicate || isBookmarkDuplicate) {
   1620        let msgid;
   1621        if (isEngineDuplicate) {
   1622          msgid = {
   1623            id: "search-keyword-warning-engine",
   1624            args: { name: dupEngine.name },
   1625          };
   1626        } else {
   1627          msgid = { id: "search-keyword-warning-bookmark" };
   1628        }
   1629 
   1630        let msg = await document.l10n.formatValue(msgid.id, msgid.args);
   1631        alert(msg);
   1632        return false;
   1633      }
   1634    }
   1635 
   1636    this._engineStore.changeEngine(aEngine, "alias", keyword);
   1637    this.invalidate();
   1638    return true;
   1639  }
   1640 
   1641  /**
   1642   * Handles changing the name for a user engine. This will check for
   1643   * duplicate names and warn the user if necessary.
   1644   *
   1645   * @param {object} aEngine
   1646   *   The user search engine to change.
   1647   * @param {string} aNewName
   1648   *   The new name.
   1649   * @returns {Promise<boolean>}
   1650   *   Resolves to true if the name was changed.
   1651   */
   1652  async #changeName(aEngine, aNewName) {
   1653    let valid = aEngine.originalEngine.wrappedJSObject.rename(aNewName);
   1654    if (!valid) {
   1655      let msg = await document.l10n.formatValue(
   1656        "edit-engine-name-warning-duplicate",
   1657        { name: aNewName }
   1658      );
   1659      alert(msg);
   1660      return false;
   1661    }
   1662    return true;
   1663  }
   1664 }