Bug 1647888 - Implement visual indicator in the Urlbar for aliased engine. r=dao,mak
authorHarry Twyford <htwyford@mozilla.com>
Thu, 23 Jul 2020 18:41:32 +0000
changeset 541820 a27b933d7a825ee74a142305e6bd9ef990e82fa7
parent 541819 63f8e5c9497d3e27af04c5d7cff61feab714f111
child 541821 8262943652a0d26c330c31e2059705eb6284cd96
push id37633
push userccoroiu@mozilla.com
push dateFri, 24 Jul 2020 09:32:06 +0000
treeherdermozilla-central@141543043270 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersdao, mak
bugs1647888
milestone80.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1647888 - Implement visual indicator in the Urlbar for aliased engine. r=dao,mak Differential Revision: https://phabricator.services.mozilla.com/D83854
browser/base/content/browser.xhtml
browser/components/search/content/search-one-offs.js
browser/components/urlbar/UrlbarController.jsm
browser/components/urlbar/UrlbarInput.jsm
browser/components/urlbar/UrlbarPrefs.jsm
browser/components/urlbar/UrlbarView.jsm
browser/components/urlbar/tests/UrlbarTestUtils.jsm
browser/components/urlbar/tests/browser/browser.ini
browser/components/urlbar/tests/browser/browser_oneOffs.js
browser/components/urlbar/tests/browser/browser_searchModeIndicator.js
browser/themes/shared/urlbar-searchbar.inc.css
--- a/browser/base/content/browser.xhtml
+++ b/browser/base/content/browser.xhtml
@@ -1606,16 +1606,22 @@
                 <image id="remote-control-icon"
                        data-l10n-id="urlbar-remote-control-notification-anchor"/>
                 <label id="identity-icon-label" class="plain" crop="center" flex="1"/>
               </box>
               <box id="urlbar-label-box" align="center">
                 <label id="urlbar-label-switchtab" class="urlbar-label" data-l10n-id="urlbar-switch-to-tab"/>
                 <label id="urlbar-label-extension" class="urlbar-label" data-l10n-id="urlbar-extension"/>
               </box>
+              <html:div id="urlbar-search-mode-indicator">
+                <html:span id="urlbar-search-mode-indicator-title"/>
+                <html:div id="urlbar-search-mode-indicator-close"
+                       class="close-button"
+                       role="button"/>
+              </html:div>
               <moz-input-box tooltip="aHTMLTooltip"
                              class="urlbar-input-box"
                              flex="1"
                              role="combobox"
                              aria-owns="urlbar-results">
                 <html:input id="urlbar-scheme"
                             required="required"/>
                 <html:input id="urlbar-input"
--- a/browser/components/search/content/search-one-offs.js
+++ b/browser/components/search/content/search-one-offs.js
@@ -710,19 +710,22 @@ class SearchOneOffs {
         this.compact ? this.settingsButtonCompact : this.settingsButton
       );
     }
 
     return buttons;
   }
 
   handleSearchCommand(aEvent, aEngine, aForceNewTab) {
+    if (this._view?.oneOffsCommandHandler(aEvent, aEngine)) {
+      return;
+    }
+
     let where = "current";
     let params;
-
     // Open ctrl/cmd clicks on one-off buttons in a new background tab.
     if (aForceNewTab) {
       where = "tab";
       if (Services.prefs.getBoolPref("browser.tabs.loadInBackground")) {
         params = {
           inBackground: true,
         };
       }
@@ -1113,16 +1116,20 @@ class SearchOneOffs {
       target.classList.contains("addengine-menu-button")
     ) {
       this._addEngineMenuShouldBeOpen = false;
       this._resetAddEngineMenuTimeout();
     }
   }
 
   _on_click(event) {
+    if (this._view?.oneOffsClickHandler(event)) {
+      return;
+    }
+
     if (event.button == 2) {
       return; // ignore right clicks.
     }
 
     let button = event.originalTarget;
     let engine = button.engine;
 
     if (!engine) {
--- a/browser/components/urlbar/UrlbarController.jsm
+++ b/browser/components/urlbar/UrlbarController.jsm
@@ -306,17 +306,31 @@ class UrlbarController {
           } else {
             this.input.handleRevert();
           }
         }
         event.preventDefault();
         break;
       case KeyEvent.DOM_VK_RETURN:
         if (executeAction) {
-          this.input.handleCommand(event);
+          if (
+            this.view.oneOffsRefresh &&
+            this.view.oneOffSearchButtons.selectedButton?.engine
+          ) {
+            this.input.setSearchMode(
+              this.view.oneOffSearchButtons.selectedButton.engine
+            );
+            this.view.oneOffSearchButtons.selectedButton = null;
+            this.input.startQuery({
+              allowAutofill: false,
+              event,
+            });
+          } else {
+            this.input.handleCommand(event);
+          }
         }
         event.preventDefault();
         break;
       case KeyEvent.DOM_VK_TAB:
         // It's always possible to tab through results when the urlbar was
         // focused with the mouse, or has a search string.
         // When there's no search string, we want to focus the next toolbar item
         // instead, for accessibility reasons.
@@ -374,18 +388,33 @@ class UrlbarController {
         event.preventDefault();
         break;
       case KeyEvent.DOM_VK_LEFT:
       case KeyEvent.DOM_VK_RIGHT:
       case KeyEvent.DOM_VK_HOME:
       case KeyEvent.DOM_VK_END:
         this.view.removeAccessibleFocus();
         break;
+      case KeyEvent.DOM_VK_BACK_SPACE:
+        if (
+          this.input.searchMode &&
+          this.input.selectionStart == 0 &&
+          this.input.selectionEnd == 0 &&
+          !event.shiftKey
+        ) {
+          this.input.setSearchMode(null);
+          if (this.input.value) {
+            this.input.startQuery({
+              allowAutofill: false,
+              event,
+            });
+          }
+        }
+      // Fall through.
       case KeyEvent.DOM_VK_DELETE:
-      case KeyEvent.DOM_VK_BACK_SPACE:
         if (!this.view.isOpen) {
           break;
         }
         if (event.shiftKey) {
           if (!executeAction || this._handleDeleteEntry()) {
             event.preventDefault();
           }
         } else if (executeAction) {
--- a/browser/components/urlbar/UrlbarInput.jsm
+++ b/browser/components/urlbar/UrlbarInput.jsm
@@ -12,16 +12,17 @@ const { XPCOMUtils } = ChromeUtils.impor
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AppConstants: "resource://gre/modules/AppConstants.jsm",
   BrowserUtils: "resource://gre/modules/BrowserUtils.jsm",
   ExtensionSearchHandler: "resource://gre/modules/ExtensionSearchHandler.jsm",
   FormHistory: "resource://gre/modules/FormHistory.jsm",
   PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm",
   ReaderMode: "resource://gre/modules/ReaderMode.jsm",
+  SearchEngine: "resource://gre/modules/SearchEngine.jsm",
   Services: "resource://gre/modules/Services.jsm",
   TopSiteAttribution: "resource:///modules/TopSiteAttribution.jsm",
   UrlbarController: "resource:///modules/UrlbarController.jsm",
   UrlbarEventBufferer: "resource:///modules/UrlbarEventBufferer.jsm",
   UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm",
   UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm",
   UrlbarQueryContext: "resource:///modules/UrlbarUtils.jsm",
   UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.jsm",
@@ -148,16 +149,25 @@ class UrlbarInput {
           return (this.inputField[property] = val);
         },
       });
     }
 
     this.inputField = this.querySelector("#urlbar-input");
     this._inputContainer = this.querySelector("#urlbar-input-container");
     this._identityBox = this.querySelector("#identity-box");
+    this._searchModeIndicator = this.querySelector(
+      "#urlbar-search-mode-indicator"
+    );
+    this._searchModeIndicatorTitle = this._searchModeIndicator.querySelector(
+      "#urlbar-search-mode-indicator-title"
+    );
+    this._searchModeIndicatorClose = this._searchModeIndicator.querySelector(
+      "#urlbar-search-mode-indicator-close"
+    );
     this._toolbar = this.textbox.closest("toolbar");
 
     XPCOMUtils.defineLazyGetter(this, "valueFormatter", () => {
       return new UrlbarValueFormatter(this);
     });
 
     // If the toolbar is not visible in this window or the urlbar is readonly,
     // we'll stop here, so that most properties of the input object are valid,
@@ -197,16 +207,18 @@ class UrlbarInput {
 
     this.window.addEventListener("mousedown", this);
     if (AppConstants.platform == "win") {
       this.window.addEventListener("draggableregionleftmousedown", this);
     }
     this.textbox.addEventListener("mousedown", this);
     this._inputContainer.addEventListener("click", this);
 
+    this._searchModeIndicatorClose.addEventListener("click", this);
+
     // This is used to detect commands launched from the panel, to avoid
     // recording abandonment events when the command causes a blur event.
     this.view.panel.addEventListener("command", this, true);
 
     this.window.gBrowser.tabContainer.addEventListener("TabSelect", this);
 
     this.window.addEventListener("customizationstarting", this);
     this.window.addEventListener("aftercustomization", this);
@@ -382,16 +394,19 @@ class UrlbarInput {
    */
   handleCommand(event, openWhere, openParams = {}, triggeringPrincipal = null) {
     let isMouseEvent = event instanceof this.window.MouseEvent;
     if (isMouseEvent && event.button == 2) {
       // Do nothing for right clicks.
       return;
     }
 
+    let element = this.view.selectedElement;
+    let result = this.view.getResultFromElement(element);
+
     // Determine whether to use the selected one-off search button.  In
     // one-off search buttons parlance, "selected" means that the button
     // has been navigated to via the keyboard.  So we want to use it if
     // the triggering event is not a mouse click -- i.e., it's a Return
     // key -- or if the one-off was mouse-clicked.
     let selectedOneOff;
     if (this.view.isOpen) {
       selectedOneOff = this.view.oneOffSearchButtons.selectedButton;
@@ -399,22 +414,27 @@ class UrlbarInput {
         selectedOneOff = null;
       }
       // Do the command of the selected one-off if it's not an engine.
       if (selectedOneOff && !selectedOneOff.engine) {
         this.controller.engagementEvent.discard();
         selectedOneOff.doCommand();
         return;
       }
+
+      // If the user clicked on a filter-style one-off, we want to avoid the
+      // handling below to execute a search. One-off code in this function can
+      // be simplified when update2 is on by default.
+      if (this.view.oneOffsRefresh && !result?.heuristic) {
+        selectedOneOff = null;
+      }
     }
 
     // Use the selected element if we have one; this is usually the case
     // when the view is open.
-    let element = this.view.selectedElement;
-    let result = this.view.getResultFromElement(element);
     let selectedPrivateResult =
       result &&
       result.type == UrlbarUtils.RESULT_TYPE.SEARCH &&
       result.payload.inPrivateWindow;
     let selectedPrivateEngineResult =
       selectedPrivateResult && result.payload.isPrivateEngine;
     if (element && (!selectedOneOff || selectedPrivateEngineResult)) {
       this.pickElement(element, event);
@@ -533,16 +553,17 @@ class UrlbarInput {
         }
       });
     // Don't add further handling here, the catch above is our last resort.
   }
 
   handleRevert() {
     this.window.gBrowser.userTypedValue = null;
     this.setURI(null, true);
+    this.setSearchMode(null);
     if (this.value && this.focused) {
       this.select();
     }
   }
 
   /**
    * Called when an element of the view is picked.
    *
@@ -1122,16 +1143,49 @@ class UrlbarInput {
   removeHiddenFocus() {
     this._hideFocus = false;
     if (this.focused) {
       this.setAttribute("focused", "true");
       this.startLayoutExtend();
     }
   }
 
+  /**
+   * Sets search mode and shows the search mode indicator.
+   *
+   * @param {nsISearchEngine | string} engineOrMode
+   *   Either the search engine to restrict to or a mode described by a string.
+   *   Exits search mode if null.
+   */
+  setSearchMode(engineOrMode) {
+    if (!UrlbarPrefs.get("update2")) {
+      return;
+    }
+
+    let indicatorTitle;
+    if (!engineOrMode) {
+      this.searchMode = null;
+      this.removeAttribute("searchmode");
+    } else if (
+      engineOrMode instanceof Ci.nsISearchEngine ||
+      engineOrMode instanceof SearchEngine
+    ) {
+      this.searchMode = {
+        source: UrlbarUtils.RESULT_SOURCE.SEARCH,
+        engineName: engineOrMode.name,
+      };
+      indicatorTitle = engineOrMode.name;
+      this.toggleAttribute("searchmode", true);
+    } else {
+      // TODO: Support non-RESULT_SOURCE.SEARCH search modes (bug 1647896).
+    }
+
+    this._searchModeIndicatorTitle.textContent = indicatorTitle;
+  }
+
   // Getters and Setters below.
 
   get editor() {
     return this.inputField.editor;
   }
 
   get focused() {
     return this.document.activeElement == this.inputField;
@@ -1835,16 +1889,18 @@ class UrlbarInput {
       params.initiatingDoc = this.window.document;
     }
 
     // Focus the content area before triggering loads, since if the load
     // occurs in a new tab, we want focus to be restored to the content
     // area when the current tab is re-selected.
     browser.focus();
 
+    this.setSearchMode(null);
+
     if (openUILinkWhere != "current") {
       this.handleRevert();
     }
 
     // Notify about the start of navigation.
     this._notifyStartNavigation(resultDetails);
 
     try {
@@ -2065,16 +2121,25 @@ class UrlbarInput {
   _on_click(event) {
     if (
       event.target == this.inputField ||
       event.target == this._inputContainer ||
       event.target.id == SEARCH_BUTTON_ID
     ) {
       this._maybeSelectAll();
     }
+
+    if (event.target == this._searchModeIndicatorClose && event.button != 2) {
+      this.setSearchMode(null);
+      if (this.view.isOpen) {
+        this.startQuery({
+          event,
+        });
+      }
+    }
   }
 
   _on_contextmenu(event) {
     // Context menu opened via keyboard shortcut.
     if (!event.button) {
       return;
     }
 
@@ -2226,17 +2291,19 @@ class UrlbarInput {
     if (
       this.getAttribute("pageproxystate") == "valid" &&
       this.value != this._lastValidURLStr
     ) {
       this.setPageProxyState("invalid", true);
     }
 
     let canShowTopSites =
-      !this.isPrivate && UrlbarPrefs.get("suggest.topsites");
+      !this.isPrivate &&
+      UrlbarPrefs.get("suggest.topsites") &&
+      !this.searchMode;
     if (!this.view.isOpen || (!value && !canShowTopSites)) {
       this.view.clear();
     }
     if (!value && !canShowTopSites) {
       this.view.close();
       return;
     }
 
@@ -2263,17 +2330,17 @@ class UrlbarInput {
     let allowAutofill =
       !!event.data &&
       !UrlbarUtils.isPasteEvent(event) &&
       this._maybeAutofillOnInput(value);
 
     this.startQuery({
       searchString: value,
       allowAutofill,
-      resetSearchState: false,
+      resetSearchState: !!this.searchMode,
       event,
     });
   }
 
   _on_select(event) {
     // On certain user input, AutoCopyListener::OnSelectionChange() updates
     // the primary selection with user-selected text (when supported).
     // Selection::NotifySelectionListeners() then dispatches a "select" event
--- a/browser/components/urlbar/UrlbarPrefs.jsm
+++ b/browser/components/urlbar/UrlbarPrefs.jsm
@@ -72,16 +72,19 @@ const PREF_URLBAR_DEFAULTS = new Map([
 
   // Whether we expand the font size when when the urlbar is
   // focused.
   ["experimental.expandTextOnFocus", false],
 
   // Whether the urlbar displays a permanent search button.
   ["experimental.searchButton", false],
 
+  // Whether we style the search mode indicator's close button on hover.
+  ["experimental.searchModeIndicatorHover", false],
+
   // When true, `javascript:` URLs are not included in search results.
   ["filter.javascript", true],
 
   // Applies URL highlighting and other styling to the text in the urlbar input.
   ["formatting.enabled", true],
 
   // Controls the composition of search results.
   ["matchBuckets", "suggestion:4,general:Infinity"],
--- a/browser/components/urlbar/UrlbarView.jsm
+++ b/browser/components/urlbar/UrlbarView.jsm
@@ -91,16 +91,26 @@ class UrlbarView {
         this
       );
     }
     return this._oneOffSearchButtons;
   }
 
   /**
    * @returns {boolean}
+   *   Whether the update2 one-offs are used.
+   */
+  get oneOffsRefresh() {
+    return (
+      UrlbarPrefs.get("update2") && UrlbarPrefs.get("update2.oneOffsRefresh")
+    );
+  }
+
+  /**
+   * @returns {boolean}
    *   Whether the panel is open.
    */
   get isOpen() {
     return this.input.hasAttribute("open");
   }
 
   get allowEmptySelection() {
     return !(
@@ -607,26 +617,72 @@ class UrlbarView {
       this[methodName](event);
     } else {
       throw new Error("Unrecognized UrlbarView event: " + event.type);
     }
   }
 
   /**
    * This is called when a one-off is clicked and when "search in new tab"
-   * is selected from a one-off context menu.
+   * is selected from a one-off context menu. Can be removed when update2 is
+   * on by default.
    * @param {Event} event
    * @param {nsISearchEngine} engine
    * @param {string} where
    * @param {object} params
    */
   handleOneOffSearch(event, engine, where, params) {
     this.input.handleCommand(event, where, params);
   }
 
+  /**
+   * Handles a command from a one-off button.
+   *
+   * @param {Event} event The one-off selection event.
+   * @param {nsISearchEngine} engine The engine associated with the one-off.
+   * @returns {boolean} True if this handler managed the event.
+   */
+  oneOffsCommandHandler(event, engine) {
+    if (!this.oneOffsRefresh) {
+      return false;
+    }
+
+    this.input.setSearchMode(engine);
+    this.input.startQuery({
+      allowAutofill: false,
+      event,
+    });
+    return true;
+  }
+
+  /**
+   * Handles a click on a one-off button.
+   *
+   * @param {Event} event The one-off click event.
+   * @returns {boolean} True if this handler managed the event.
+   */
+  oneOffsClickHandler(event) {
+    if (!this.oneOffsRefresh) {
+      return false;
+    }
+
+    if (event.button == 2) {
+      return false; // ignore right clicks.
+    }
+
+    let button = event.originalTarget;
+    let engine = button.engine;
+
+    if (!engine) {
+      return false;
+    }
+
+    return this.oneOffsCommandHandler(event, engine);
+  }
+
   static dynamicViewTemplatesByName = new Map();
 
   /**
    * Registers the view template for a dynamic result type.  A view template is
    * a plain object that describes the DOM subtree for a dynamic result type.
    * When a dynamic result is shown in the urlbar view, its type's view template
    * is used to construct the part of the view that represents the result.
    *
@@ -1709,16 +1765,24 @@ class UrlbarView {
       if (
         result.type != UrlbarUtils.RESULT_TYPE.SEARCH ||
         (!result.heuristic &&
           (!result.payload.suggestion || result.payload.isSearchHistory) &&
           (!result.payload.inPrivateWindow || result.payload.isPrivateEngine))
       ) {
         continue;
       }
+
+      if (
+        this.oneOffsRefresh &&
+        !result.heuristic &&
+        (!result.payload.inPrivateWindow || result.payload.isPrivateEngine)
+      ) {
+        continue;
+      }
       if (engine) {
         if (!result.payload.originalEngine) {
           result.payload.originalEngine = result.payload.engine;
         }
         result.payload.engine = engine.name;
       } else if (result.payload.originalEngine) {
         result.payload.engine = result.payload.originalEngine;
         delete result.payload.originalEngine;
--- a/browser/components/urlbar/tests/UrlbarTestUtils.jsm
+++ b/browser/components/urlbar/tests/UrlbarTestUtils.jsm
@@ -342,16 +342,37 @@ var UrlbarTestUtils = {
    * @param {object} win The browser window
    * @returns {boolean} Whether the popup is open
    */
   isPopupOpen(win) {
     return win.gURLBar.view.isOpen;
   },
 
   /**
+   * @param {object} win The browser window
+   * @param {string} [engineName]
+   * @returns {boolean} True if the UrlbarInput is in search mode. If
+   *   engineName is specified, only returns true if the search mode engine
+   *   matches.
+   */
+  isInSearchMode(win, engineName = null) {
+    if (!!win.gURLBar.searchMode != win.gURLBar.hasAttribute("searchmode")) {
+      throw new Error(
+        "Urlbar should never be in search mode without the corresponding attribute."
+      );
+    }
+
+    if (engineName) {
+      return win.gURLBar.searchMode.engineName == engineName;
+    }
+
+    return !!win.gURLBar.searchMode;
+  },
+
+  /**
    * Returns the userContextId (container id) for the last search.
    * @param {object} win The browser window
    * @returns {Promise} resolved when fetching is complete
    * @resolves {number} a userContextId
    */
   async promiseUserContextId(win) {
     const defaultId = Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID;
     let context = await win.gURLBar.lastQueryContextPromise;
--- a/browser/components/urlbar/tests/browser/browser.ini
+++ b/browser/components/urlbar/tests/browser/browser.ini
@@ -132,16 +132,17 @@ run-if = e10s
 [browser_remove_match.js]
 [browser_removeUnsafeProtocolsFromURLBarPaste.js]
 fail-if = (os == 'linux' && os_version == '18.04')  # Bug 1600182
 [browser_restoreEmptyInput.js]
 [browser_resultSpan.js]
 [browser_retainedResultsOnFocus.js]
 [browser_revert.js]
 [browser_searchFunction.js]
+[browser_searchModeIndicator.js]
 [browser_searchSettings.js]
 [browser_searchSingleWordNotification.js]
 [browser_searchSuggestions.js]
 support-files =
   searchSuggestionEngine.xml
   searchSuggestionEngine.sjs
 [browser_selectionKeyNavigation.js]
 [browser_selectStaleResults.js]
--- a/browser/components/urlbar/tests/browser/browser_oneOffs.js
+++ b/browser/components/urlbar/tests/browser/browser_oneOffs.js
@@ -1,29 +1,39 @@
 const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml";
 
 let gMaxResults;
+let engine;
 
 XPCOMUtils.defineLazyGetter(this, "oneOffSearchButtons", () => {
   return UrlbarTestUtils.getOneOffSearchButtons(window);
 });
 
 add_task(async function init() {
   gMaxResults = Services.prefs.getIntPref("browser.urlbar.maxRichResults");
 
   // Add a search suggestion engine and move it to the front so that it appears
   // as the first one-off.
-  let engine = await SearchTestUtils.promiseNewSearchEngine(
+  engine = await SearchTestUtils.promiseNewSearchEngine(
     getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME
   );
   await Services.search.moveEngine(engine, 0);
 
+  Services.prefs.setBoolPref(
+    "browser.search.separatePrivateDefault.ui.enabled",
+    false
+  );
   registerCleanupFunction(async function() {
     await PlacesUtils.history.clear();
     await UrlbarTestUtils.formHistory.clear();
+    Services.prefs.clearUserPref(
+      "browser.search.separatePrivateDefault.ui.enabled"
+    );
+    Services.prefs.clearUserPref("browser.urlbar.update2");
+    Services.prefs.clearUserPref("browser.urlbar.update2.oneOffsRefresh");
   });
 
   // Initialize history with enough visits to fill up the view.
   await PlacesUtils.history.clear();
   await UrlbarTestUtils.formHistory.clear();
   for (let i = 0; i < gMaxResults; i++) {
     await PlacesTestUtils.addVisits(
       "http://example.com/browser_urlbarOneOffs.js/?" + i
@@ -223,106 +233,201 @@ add_task(async function editedView() {
     BrowserTestUtils.is_visible(heuristicResult.element.action),
     "The heuristic action should be visible"
   );
 
   await hidePopup();
 });
 
 // Checks that "Search with Current Search Engine" items are updated to "Search
-// with One-Off Engine" when a one-off is selected.
+// with One-Off Engine" when a one-off is selected. If update2 one-offs are
+// enabled, only the heuristic result should update.
 add_task(async function searchWith() {
+  // Enable suggestions for this subtest so we can check non-heuristic results.
+  let oldDefaultEngine = await Services.search.getDefault();
+  let oldSuggestPref = Services.prefs.getBoolPref(
+    "browser.urlbar.suggest.searches"
+  );
+  await Services.search.setDefault(engine);
+  Services.prefs.setBoolPref("browser.urlbar.suggest.searches", true);
+
   let typedValue = "foo";
   await UrlbarTestUtils.promiseAutocompleteResultPopup({
     window,
     value: typedValue,
   });
   let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0);
   assertState(0, -1, typedValue);
 
   Assert.equal(
     result.displayed.action,
     "Search with " + (await Services.search.getDefault()).name,
     "Sanity check: first result's action text"
   );
 
-  // Alt+Down to the first one-off.  Now the first result and the first one-off
-  // should both be selected.
-  EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
-  assertState(0, 0, typedValue);
+  // Alt+Down to the second one-off.  Now the first result and the second
+  // one-off should both be selected.
+  EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true, repeat: 2 });
+  assertState(0, 1, typedValue);
 
   let engineName = oneOffSearchButtons.selectedButton.engine.name;
   Assert.notEqual(
     engineName,
     (await Services.search.getDefault()).name,
-    "Sanity check: First one-off engine should not be the current engine"
+    "Sanity check: Second one-off engine should not be the current engine"
   );
   result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0);
   Assert.equal(
     result.displayed.action,
     "Search with " + engineName,
     "First result's action text should be updated"
   );
 
+  // Check non-heuristic results.
+  for (let refresh of [true, false]) {
+    UrlbarPrefs.set("update2", refresh);
+    UrlbarPrefs.set("update2.oneOffsRefresh", refresh);
+    await hidePopup();
+    await UrlbarTestUtils.promiseAutocompleteResultPopup({
+      window,
+      value: typedValue,
+    });
+
+    EventUtils.synthesizeKey("KEY_ArrowDown");
+    result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1);
+    assertState(1, -1, typedValue + "foo");
+    Assert.equal(
+      result.displayed.action,
+      "Search with " + engine.name,
+      "Sanity check: second result's action text"
+    );
+    Assert.ok(!result.heuristic, "The second result is not heuristic.");
+    EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true, repeat: 2 });
+    assertState(1, 1, typedValue + "foo");
+
+    engineName = oneOffSearchButtons.selectedButton.engine.name;
+    Assert.notEqual(
+      engineName,
+      (await Services.search.getDefault()).name,
+      "Sanity check: Second one-off engine should not be the current engine"
+    );
+    result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1);
+
+    if (refresh) {
+      Assert.equal(
+        result.displayed.action,
+        "Search with " + (await Services.search.getDefault()).name,
+        "Second result's action text should be the same"
+      );
+    } else {
+      Assert.equal(
+        result.displayed.action,
+        "Search with " + engineName,
+        "Second result's action text should be updated"
+      );
+    }
+  }
+
+  Services.prefs.setBoolPref("browser.urlbar.suggest.searches", oldSuggestPref);
+  await Services.search.setDefault(oldDefaultEngine);
   await hidePopup();
 });
 
 // Clicks a one-off.
 add_task(async function oneOffClick() {
   gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
 
   // We are explicitly using something that looks like a url, to make the test
   // stricter. Even if it looks like a url, we should search.
   let typedValue = "foo.bar";
-  await UrlbarTestUtils.promiseAutocompleteResultPopup({
-    window,
-    value: typedValue,
-  });
-  await UrlbarTestUtils.getDetailsOfResultAt(window, 0);
-  assertState(0, -1, typedValue);
+
+  for (let refresh of [true, false]) {
+    UrlbarPrefs.set("update2", refresh);
+    UrlbarPrefs.set("update2.oneOffsRefresh", refresh);
+
+    await UrlbarTestUtils.promiseAutocompleteResultPopup({
+      window,
+      value: typedValue,
+    });
+    await UrlbarTestUtils.getDetailsOfResultAt(window, 0);
+    assertState(0, -1, typedValue);
+    let oneOffs = oneOffSearchButtons.getSelectableButtons(true);
 
-  let oneOffs = oneOffSearchButtons.getSelectableButtons(true);
-  let resultsPromise = BrowserTestUtils.browserLoaded(
-    gBrowser.selectedBrowser,
-    false,
-    "http://mochi.test:8888/?terms=foo.bar"
-  );
-  EventUtils.synthesizeMouseAtCenter(oneOffs[0], {});
-  await resultsPromise;
+    if (refresh) {
+      EventUtils.synthesizeMouseAtCenter(oneOffs[0], {});
+      Assert.ok(
+        UrlbarTestUtils.isPopupOpen(window),
+        "Urlbar view is still open."
+      );
+      Assert.ok(
+        UrlbarTestUtils.isInSearchMode(window, oneOffs[0].engine.name),
+        "The Urlbar is in search mode."
+      );
+      window.gURLBar.setSearchMode(null);
+    } else {
+      let resultsPromise = BrowserTestUtils.browserLoaded(
+        gBrowser.selectedBrowser,
+        false,
+        "http://mochi.test:8888/?terms=foo.bar"
+      );
+      EventUtils.synthesizeMouseAtCenter(oneOffs[0], {});
+      await resultsPromise;
+    }
+  }
 
   gBrowser.removeTab(gBrowser.selectedTab);
   await UrlbarTestUtils.formHistory.clear();
 });
 
 // Presses the Return key when a one-off is selected.
 add_task(async function oneOffReturn() {
   gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
 
   // We are explicitly using something that looks like a url, to make the test
   // stricter. Even if it looks like a url, we should search.
   let typedValue = "foo.bar";
-  await UrlbarTestUtils.promiseAutocompleteResultPopup({
-    window,
-    value: typedValue,
-    fireInputEvent: true,
-  });
-  await UrlbarTestUtils.getDetailsOfResultAt(window, 0);
-  assertState(0, -1, typedValue);
+
+  for (let refresh of [true, false]) {
+    UrlbarPrefs.set("update2", refresh);
+    UrlbarPrefs.set("update2.oneOffsRefresh", refresh);
+
+    await UrlbarTestUtils.promiseAutocompleteResultPopup({
+      window,
+      value: typedValue,
+      fireInputEvent: true,
+    });
+    await UrlbarTestUtils.getDetailsOfResultAt(window, 0);
+    assertState(0, -1, typedValue);
+    let oneOffs = oneOffSearchButtons.getSelectableButtons(true);
+
+    // Alt+Down to select the first one-off.
+    EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
+    assertState(0, 0, typedValue);
 
-  // Alt+Down to select the first one-off.
-  EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true });
-  assertState(0, 0, typedValue);
-
-  let resultsPromise = BrowserTestUtils.browserLoaded(
-    gBrowser.selectedBrowser,
-    false,
-    "http://mochi.test:8888/?terms=foo.bar"
-  );
-  EventUtils.synthesizeKey("KEY_Enter");
-  await resultsPromise;
+    if (refresh) {
+      EventUtils.synthesizeKey("KEY_Enter");
+      Assert.ok(
+        UrlbarTestUtils.isPopupOpen(window),
+        "Urlbar view is still open."
+      );
+      Assert.ok(
+        UrlbarTestUtils.isInSearchMode(window, oneOffs[0].engine.name),
+        "The Urlbar is in search mode."
+      );
+      window.gURLBar.setSearchMode(null);
+    } else {
+      let resultsPromise = BrowserTestUtils.browserLoaded(
+        gBrowser.selectedBrowser,
+        false,
+        "http://mochi.test:8888/?terms=foo.bar"
+      );
+      EventUtils.synthesizeKey("KEY_Enter");
+      await resultsPromise;
+    }
+  }
 
   gBrowser.removeTab(gBrowser.selectedTab);
   await UrlbarTestUtils.formHistory.clear();
 });
 
 add_task(async function hiddenOneOffs() {
   // Disable all the engines but the current one, check the oneoffs are
   // hidden and that moving up selects the last match.
new file mode 100644
--- /dev/null
+++ b/browser/components/urlbar/tests/browser/browser_searchModeIndicator.js
@@ -0,0 +1,192 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests interactions with the search mode indicator. See browser_oneOffs.js for
+ * more coverage.
+ */
+
+const TEST_QUERY = "test string";
+
+add_task(async function setup() {
+  SpecialPowers.pushPrefEnv({
+    set: [
+      ["browser.urlbar.suggest.searches", false],
+      ["browser.urlbar.update2", true],
+      ["browser.urlbar.update2.oneOffsRefresh", true],
+    ],
+  });
+});
+
+/**
+ * Enters search mode by clicking the first one-off.
+ * @param {object} window
+ */
+async function enterSearchMode(window) {
+  let oneOffs = UrlbarTestUtils.getOneOffSearchButtons(
+    window
+  ).getSelectableButtons(true);
+  let searchPromise = UrlbarTestUtils.promiseSearchComplete(window);
+  EventUtils.synthesizeMouseAtCenter(oneOffs[0], {});
+  await searchPromise;
+  Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Urlbar view is still open.");
+  Assert.ok(
+    UrlbarTestUtils.isInSearchMode(window, oneOffs[0].engine.name),
+    "The Urlbar is in search mode."
+  );
+}
+
+// Tests that the indicator is removed when backspacing at the beginning of
+// the search string.
+add_task(async function backspace() {
+  // View open, with string.
+  await UrlbarTestUtils.promiseAutocompleteResultPopup({
+    window,
+    value: TEST_QUERY,
+  });
+  await enterSearchMode(window);
+  gURLBar.selectionStart = gURLBar.selectionEnd = 0;
+  EventUtils.synthesizeKey("KEY_Backspace");
+  Assert.equal(gURLBar.value, TEST_QUERY, "Urlbar value hasn't changed.");
+  Assert.ok(
+    !UrlbarTestUtils.isInSearchMode(window),
+    "The Urlbar is no longer in search mode."
+  );
+
+  // View open, no string.
+  // Open Top Sites.
+  await UrlbarTestUtils.promisePopupOpen(window, () => {
+    if (gURLBar.getAttribute("pageproxystate") == "invalid") {
+      gURLBar.handleRevert();
+    }
+    EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {});
+  });
+  await enterSearchMode(window);
+  EventUtils.synthesizeKey("KEY_Backspace");
+  Assert.equal(gURLBar.value, "", "Urlbar value is empty.");
+  Assert.ok(
+    !UrlbarTestUtils.isInSearchMode(window),
+    "The Urlbar is no longer in search mode."
+  );
+
+  // View closed, with string.
+  // Open Top Sites.
+  await UrlbarTestUtils.promiseAutocompleteResultPopup({
+    window,
+    value: TEST_QUERY,
+  });
+  await enterSearchMode(window);
+  UrlbarTestUtils.promisePopupClose(window);
+
+  gURLBar.selectionStart = gURLBar.selectionEnd = 0;
+  let searchPromise = UrlbarTestUtils.promiseSearchComplete(window);
+  EventUtils.synthesizeKey("KEY_Backspace");
+  await searchPromise;
+  Assert.equal(gURLBar.value, TEST_QUERY, "Urlbar value hasn't changed.");
+  Assert.ok(
+    !UrlbarTestUtils.isInSearchMode(window),
+    "The Urlbar is no longer in search mode."
+  );
+  Assert.ok(UrlbarTestUtils.isPopupOpen(window), "Urlbar view is now open.");
+
+  // View closed, no string.
+  await UrlbarTestUtils.promisePopupOpen(window, () => {
+    if (gURLBar.getAttribute("pageproxystate") == "invalid") {
+      gURLBar.handleRevert();
+    }
+    EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, {});
+  });
+  await enterSearchMode(window);
+  UrlbarTestUtils.promisePopupClose(window);
+
+  gURLBar.selectionStart = gURLBar.selectionEnd = 0;
+  EventUtils.synthesizeKey("KEY_Backspace");
+  Assert.equal(gURLBar.value, "", "Urlbar value is empty.");
+  Assert.ok(
+    !UrlbarTestUtils.isInSearchMode(window),
+    "The Urlbar is no longer in search mode."
+  );
+  Assert.ok(
+    !UrlbarTestUtils.isPopupOpen(window),
+    "Urlbar view is still closed."
+  );
+});
+
+// Tests the indicator's interaction with the ESC key.
+add_task(async function escape() {
+  await UrlbarTestUtils.promiseAutocompleteResultPopup({
+    window,
+    value: TEST_QUERY,
+  });
+  await enterSearchMode(window);
+
+  EventUtils.synthesizeKey("KEY_Escape");
+  Assert.ok(!UrlbarTestUtils.isPopupOpen(window, "UrlbarView is closed."));
+  Assert.equal(gURLBar.value, TEST_QUERY, "Urlbar value hasn't changed.");
+  Assert.ok(
+    UrlbarTestUtils.isInSearchMode(window),
+    "The Urlbar is in search mode."
+  );
+
+  EventUtils.synthesizeKey("KEY_Escape");
+  Assert.ok(!UrlbarTestUtils.isPopupOpen(window, "UrlbarView is closed."));
+  Assert.ok(!gURLBar.value, "Urlbar value is empty.");
+  Assert.ok(
+    !UrlbarTestUtils.isInSearchMode(window),
+    "The Urlbar is not in search mode."
+  );
+});
+
+// Tests that the indicator is removed when its close button is clicked.
+add_task(async function click_close() {
+  // Clicking close with the view open.
+  await UrlbarTestUtils.promiseAutocompleteResultPopup({
+    window,
+    value: TEST_QUERY,
+  });
+  await enterSearchMode(window);
+  // We need to hover the indicator to make the close button clickable in the
+  // test.
+  let indicator = gURLBar.querySelector("#urlbar-search-mode-indicator");
+  EventUtils.synthesizeMouseAtCenter(indicator, { type: "mouseover" });
+  let closeButton = gURLBar.querySelector(
+    "#urlbar-search-mode-indicator-close"
+  );
+  let searchPromise = UrlbarTestUtils.promiseSearchComplete(window);
+  EventUtils.synthesizeMouseAtCenter(closeButton, {});
+  await searchPromise;
+  Assert.ok(
+    !UrlbarTestUtils.isInSearchMode(window),
+    "The Urlbar is no longer in search mode."
+  );
+
+  // Clicking close with the view closed.
+  await UrlbarTestUtils.promiseAutocompleteResultPopup({
+    window,
+    value: TEST_QUERY,
+  });
+  await enterSearchMode(window);
+  UrlbarTestUtils.promisePopupClose(window);
+  if (gURLBar.hasAttribute("breakout-extend")) {
+    Assert.ok(
+      UrlbarTestUtils.isInSearchMode(window),
+      "The Urlbar is still in search mode."
+    );
+    indicator = gURLBar.querySelector("#urlbar-search-mode-indicator");
+    EventUtils.synthesizeMouseAtCenter(indicator, { type: "mouseover" });
+    closeButton = gURLBar.querySelector("#urlbar-search-mode-indicator-close");
+    EventUtils.synthesizeMouseAtCenter(closeButton, {});
+    Assert.ok(
+      !UrlbarTestUtils.isInSearchMode(window),
+      "The Urlbar is no longer in search mode."
+    );
+  } else {
+    // If the Urlbar is not extended when it is closed, do not finish this
+    // case. The close button is not clickable when the Urlbar is not
+    // extended. This scenario might be encountered on Linux, where
+    // prefers-reduced-motion is enabled in automation.
+    gURLBar.setSearchMode(null);
+  }
+});
--- a/browser/themes/shared/urlbar-searchbar.inc.css
+++ b/browser/themes/shared/urlbar-searchbar.inc.css
@@ -271,16 +271,63 @@
   min-width: 12px;
   margin: 0 -6px;
   position: relative;
   border: none;
   background: transparent;
   appearance: none;
 }
 
+/* Urlbar search mode indicator */
+#urlbar-search-mode-indicator {
+  display: none;
+  background-color: #d6ebff;
+  outline: 1px solid #0060df; /* Blue 60 */
+  -moz-outline-radius: 2px;
+  margin-inline-end: 8px;
+  min-width: 16px; /* So the close button never causes motion */
+  align-items: center;
+}
+
+#urlbar[searchmode][breakout-extend] > #urlbar-input-container > #urlbar-search-mode-indicator {
+  display: inline-flex;
+}
+
+#urlbar-search-mode-indicator-title {
+  color: #0060df; /* Blue 60 */
+  padding-inline: 5px;
+}
+
+#urlbar-search-mode-indicator-close {
+  display: none;
+  background: url(chrome://global/skin/icons/close.svg) no-repeat center;
+  background-size: 100% 16px;
+  width: 16px;
+  height: 100%;
+  margin-inline-start: -16px;
+  -moz-context-properties: fill, fill-opacity, stroke-opacity;
+  fill: #0c0c0d; /* Grey 90 */
+  fill-opacity: 0;
+  stroke-opacity: 0.6;
+  background-color: white;
+}
+
+#urlbar-search-mode-indicator:hover  > #urlbar-search-mode-indicator-close {
+  display: inline;
+}
+
+@supports -moz-bool-pref("browser.urlbar.experimental.searchModeIndicatorHover") {
+  #urlbar-search-mode-indicator-close:hover {
+    background-color: #ededf0; /* Grey 20 */
+  }
+  #urlbar-search-mode-indicator-close:hover:active {
+    background-color: #d7d7db; /* Grey 30 */
+  }
+}
+
 /* Page action panel */
 .pageAction-panel-button > .toolbarbutton-icon {
   width: 16px;
   height: 16px;
 }
 
 #pageAction-panel-bookmark,
 #star-button {