Bug 1496815 - Suggest search engine aliases in the address bar when '@' is typed as the first character r=mak
authorDrew Willcoxon <adw@mozilla.com>
Fri, 19 Oct 2018 16:08:24 +0000
changeset 490531 eab3a0cf64298bbe275a6598bea9ae73811652db
parent 490530 68445fa636530442c2ac79c80f506e86ccaa838d
child 490532 418fa1fa4f746f82b0a1a070ef9dfb12849b5281
push id247
push userfmarier@mozilla.com
push dateSat, 27 Oct 2018 01:06:44 +0000
reviewersmak
bugs1496815, 1496814, 1496811
milestone64.0a1
Bug 1496815 - Suggest search engine aliases in the address bar when '@' is typed as the first character r=mak This bug touches just about every part of the urlbar: UnifiedComplete, the autocomplete binding, the formatter, CSS. This builds on the patches in bug 1496814 and bug 1496811. Differential Revision: https://phabricator.services.mozilla.com/D8948
browser/base/content/browser.css
browser/base/content/test/urlbar/browser_urlbarHighlightSearchAlias.js
browser/components/urlbar/UrlbarValueFormatter.jsm
browser/themes/shared/urlbar-autocomplete.inc.css
toolkit/components/places/PlacesSearchAutocompleteProvider.jsm
toolkit/components/places/UnifiedComplete.js
toolkit/components/places/tests/unifiedcomplete/test_search_engine_alias.js
toolkit/content/widgets/autocomplete.xml
--- a/browser/base/content/browser.css
+++ b/browser/base/content/browser.css
@@ -630,19 +630,22 @@ html|input.urlbar-input {
   display: -moz-box;
 }
 
 #PopupAutoCompleteRichResult > richlistbox > richlistitem[selected] > .ac-action[actiontype=remotetab],
 #PopupAutoCompleteRichResult > richlistbox > richlistitem:hover > .ac-action[actiontype=remotetab] {
   display: none;
 }
 
-/* Only show the "Search with" label on hover or selection. */
-#PopupAutoCompleteRichResult > richlistbox > richlistitem:not([selected]):not(:hover) > .ac-action[actiontype=searchengine],
-#PopupAutoCompleteRichResult > richlistbox > richlistitem:not([selected]):not(:hover) > .ac-separator[actiontype=searchengine] {
+/* Normally we want to show the "Search with Engine" label only on hover or
+   selection, but we also want to show it for "alias offer" results -- that is,
+   non-heuristic search engine results that have an alias and empty query.
+   These results tell the user that they can be used to search. */
+#PopupAutoCompleteRichResult > richlistbox > richlistitem:not([selected]):not(:hover):not(.aliasOffer) > .ac-action[actiontype=searchengine],
+#PopupAutoCompleteRichResult > richlistbox > richlistitem:not([selected]):not(:hover):not(.aliasOffer) > .ac-separator[actiontype=searchengine] {
   display: none;
 }
 
 /* Hide the em-dash separator between the search query description (.ac-title)
    and the "Search with Foo" description (.ac-url) if the search query is the
    empty string.  Also hide .ac-title itself because it has a margin-inline-end;
    alternatively we would need to remove that margin in this case. */
 #PopupAutoCompleteRichResult > richlistbox > richlistitem.emptySearchQuery > .ac-separator,
--- a/browser/base/content/test/urlbar/browser_urlbarHighlightSearchAlias.js
+++ b/browser/base/content/test/urlbar/browser_urlbarHighlightSearchAlias.js
@@ -17,58 +17,77 @@ add_task(async function init() {
     Services.search.removeEngine(engine);
     // Make sure the popup is closed for the next test.
     gURLBar.handleRevert();
     gURLBar.blur();
     Assert.ok(!gURLBar.popup.popupOpen, "popup should be closed");
   });
 });
 
+
+// Simple test that tries different variations of an alias, without reverting
+// the urlbar value in between.
 add_task(async function testNoRevert() {
-  await doTest(false);
+  await doSimpleTest(false);
 });
 
+
+// Simple test that tries different variations of an alias, reverting the urlbar
+// value in between.
 add_task(async function testRevert() {
-  await doTest(true);
+  await doSimpleTest(true);
 });
 
+
+// An alias should be recognized and highlighted even when there are spaces
+// before it.
 add_task(async function spacesBeforeAlias() {
   gURLBar.search("     " + ALIAS);
   await promiseSearchComplete();
   await waitForAutocompleteResultAt(0);
   assertAlias(true);
 
   EventUtils.synthesizeKey("KEY_Escape");
   await promisePopupHidden(gURLBar.popup);
 });
 
+
+// An alias in the middle of a string should not be recognized or highlighted.
 add_task(async function charsBeforeAlias() {
   gURLBar.search("not an alias " + ALIAS);
   await promiseSearchComplete();
   await waitForAutocompleteResultAt(0);
   assertAlias(false);
 
   EventUtils.synthesizeKey("KEY_Escape");
   await promisePopupHidden(gURLBar.popup);
 });
 
+
 // Aliases are case insensitive, and the alias in the result uses the case that
 // the user typed in the input.  Make sure that searching with an alias using a
 // weird case still causes the alias to be highlighted.
 add_task(async function aliasCase() {
   let alias = "@TeSt";
   gURLBar.search(alias);
   await promiseSearchComplete();
   await waitForAutocompleteResultAt(0);
   assertAlias(true, alias);
 
   EventUtils.synthesizeKey("KEY_Escape");
   await promisePopupHidden(gURLBar.popup);
 });
 
+
+// Even when the heuristic result is a search engine result with an alias, if
+// the urlbar value does not match that result, then no alias substring in the
+// urlbar should be highlighted.  This is the case when the user uses an alias
+// to perform a search: The popup closes (preserving the results in it), the
+// urlbar value changes to the URL of the search results page, and the search
+// results page is loaded.
 add_task(async function inputDoesntMatchHeuristicResult() {
   // Do a search using the alias.
   let searchString = `${ALIAS} aaa`;
   gURLBar.search(searchString);
   await promiseSearchComplete();
   await waitForAutocompleteResultAt(0);
   assertAlias(true);
 
@@ -101,20 +120,62 @@ add_task(async function inputDoesntMatch
   // at the beginning and is not the same as the search string.
   value = `bbb ${ALIAS}`;
   gURLBar.value = `bbb ${ALIAS}`;
 
   // The alias substring should not be highlighted.
   Assert.equal(gURLBar.value, value);
   Assert.ok(gURLBar.value.includes(ALIAS));
   assertHighlighted(false, ALIAS);
+
+  // Reset for the next test.
+  gURLBar.search("");
 });
 
 
-async function doTest(revertBetweenSteps) {
+// Selecting a non-heuristic (non-first) search engine result with an alias and
+// empty query should put the alias in the urlbar and highlight it.
+add_task(async function nonHeuristicAliases() {
+  // Get the list of token alias engines (those with aliases that start with
+  // "@").
+  let tokenEngines = [];
+  for (let engine of Services.search.getEngines()) {
+    let tokenAliases = engine.wrappedJSObject._internalAliases;
+    if (tokenAliases.length) {
+      tokenEngines.push({ engine, tokenAliases });
+    }
+  }
+  if (!tokenEngines.length) {
+    Assert.ok(true, "No token alias engines, skipping task.");
+    return;
+  }
+  info("Got token alias engines: " +
+       tokenEngines.map(({ engine }) => engine.name));
+
+  // Populate the results with the list of token alias engines by searching for
+  // "@".
+  gURLBar.search("@");
+  await promiseSearchComplete();
+  await waitForAutocompleteResultAt(tokenEngines.length - 1);
+
+  // Key down to select each result in turn.  The urlbar value should be set to
+  // each alias, and each should be highlighted.
+  for (let { tokenAliases } of tokenEngines) {
+    let alias = tokenAliases[0];
+    EventUtils.synthesizeKey("KEY_ArrowDown");
+    assertHighlighted(true, alias);
+  }
+
+  // Hide the popup.
+  EventUtils.synthesizeKey("KEY_Escape");
+  await promisePopupHidden(gURLBar.popup);
+});
+
+
+async function doSimpleTest(revertBetweenSteps) {
   // "@tes" -- not an alias, no highlight
   gURLBar.search(ALIAS.substr(0, ALIAS.length - 1));
   await promiseSearchComplete();
   await waitForAutocompleteResultAt(0);
   assertAlias(false);
 
   if (revertBetweenSteps) {
     gURLBar.handleRevert();
@@ -185,15 +246,18 @@ function assertHighlighted(highlighted, 
     Ci.nsISelectionController.SELECTION_FIND
   );
   Assert.ok(selection);
   if (!highlighted) {
     Assert.equal(selection.rangeCount, 0);
     return;
   }
   Assert.equal(selection.rangeCount, 1);
-  let index = gURLBar.value.indexOf(expectedAlias);
-  Assert.ok(index >= 0);
+  let index = gURLBar.textValue.indexOf(expectedAlias);
+  Assert.ok(
+    index >= 0,
+    `gURLBar.textValue="${gURLBar.textValue}" expectedAlias="${expectedAlias}"`
+  );
   let range = selection.getRangeAt(0);
   Assert.ok(range);
   Assert.equal(range.startOffset, index);
   Assert.equal(range.endOffset, index + expectedAlias.length);
 }
--- a/browser/components/urlbar/UrlbarValueFormatter.jsm
+++ b/browser/components/urlbar/UrlbarValueFormatter.jsm
@@ -273,52 +273,70 @@ class UrlbarValueFormatter {
     }
 
     let popup = this.urlbarInput.popup;
     if (!popup) {
       // TODO: make this work with UrlbarView
       return false;
     }
 
-    // There can only be an alias to highlight if the heuristic result is
-    // an alias searchengine result and it's either currently selected or
-    // was selected when the popup was closed.  We also need to check
-    // whether a one-off search button is selected because in that case
-    // there won't be a selection but the alias should not be highlighted.
-    if ((popup.selectedIndex < 0 &&
-         popup._previousSelectedIndex != 0) ||
-        popup.selectedIndex > 0 ||
-        popup.oneOffSearchButtons.selectedButton) {
+    if (popup.oneOffSearchButtons.selectedButton) {
       return false;
     }
-    let heuristicItem = popup.richlistbox.children[0] || null;
-    if (!heuristicItem ||
-        heuristicItem.getAttribute("actiontype") != "searchengine") {
+
+    // To determine whether the input contains a search engine alias, check the
+    // value of the selected result -- whether it's a search engine result with
+    // an alias.  Actually, check the selected listbox item, not the result in
+    // the controller, because we want to continue highlighting the alias when
+    // the popup is closed and the search has stopped.  The selected index when
+    // the popup is closed is zero, however, which is why we check the previous
+    // selected index.
+    let itemIndex =
+      popup.selectedIndex < 0 ? popup._previousSelectedIndex :
+      popup.selectedIndex;
+    if (itemIndex < 0) {
       return false;
     }
-    let url = heuristicItem.getAttribute("url");
+    let item = popup.richlistbox.children[itemIndex] || null;
+
+    // This actiontype check isn't necessary because we call _parseActionUrl
+    // below and we could check action.type instead.  But since this method is
+    // called very often, as an optimization, first do a simple string
+    // comparison on actiontype before continuing with the more expensive regexp
+    // that _parseActionUrl uses.
+    if (!item || item.getAttribute("actiontype") != "searchengine") {
+      return false;
+    }
+
+    let url = item.getAttribute("url");
     let action = this.urlbarInput._parseActionUrl(url);
     if (!action) {
       return false;
     }
     let alias = action.params.alias || null;
     if (!alias) {
       return false;
     }
 
     let editor = this.urlbarInput.editor;
     let textNode = editor.rootElement.firstChild;
     let value = textNode.textContent;
 
-    // Make sure the heuristic result's input matches the current urlbar input
-    // because the urlbar input can change without the popup results changing.
-    // Most notably that happens when the user performs a search using an alias:
-    // The popup closes, the search results page is loaded, and the urlbar value
-    // is set to the URL of the page.
-    if (decodeURIComponent(action.params.input) != value) {
+    // Make sure the item's input matches the current urlbar input because the
+    // urlbar input can change without the popup results changing.  Most notably
+    // that happens when the user performs a search using an alias: The popup
+    // closes (preserving its items), the search results page is loaded, and the
+    // urlbar value is set to the URL of the page.
+    //
+    // If the item is the heuristic item, then its input is the value that the
+    // user has typed in the input.  If the item is not the heuristic item, then
+    // its input is "@engine ".  So in order to make sure the item's input
+    // matches the current urlbar input, we need to check that the urlbar input
+    // starts with the item's input.
+    if (!value.trim().startsWith(decodeURIComponent(action.params.input).trim())) {
       return false;
     }
 
     let index = value.indexOf(alias);
     if (index < 0) {
       return false;
     }
 
--- a/browser/themes/shared/urlbar-autocomplete.inc.css
+++ b/browser/themes/shared/urlbar-autocomplete.inc.css
@@ -147,17 +147,18 @@
 }
 
 /* Awesomebar popup items */
 
 .ac-separator:not([selected=true]) {
   color: var(--panel-disabled-color);
 }
 
-.ac-url:not([selected=true]) {
+.ac-url:not([selected=true]),
+.autocomplete-richlistitem.aliasOffer > .ac-action:not([selected=true]) {
   color: var(--urlbar-popup-url-color);
 }
 
 .ac-action:not([selected=true]) {
   color: var(--urlbar-popup-action-color);
 }
 
 html|span.ac-tag {
--- a/toolkit/components/places/PlacesSearchAutocompleteProvider.jsm
+++ b/toolkit/components/places/PlacesSearchAutocompleteProvider.jsm
@@ -14,29 +14,35 @@ ChromeUtils.import("resource://gre/modul
 
 ChromeUtils.defineModuleGetter(this, "SearchSuggestionController",
   "resource://gre/modules/SearchSuggestionController.jsm");
 
 const SEARCH_ENGINE_TOPIC = "browser-search-engine-modified";
 
 const SearchAutocompleteProviderInternal = {
   /**
-   * {Map} domain string => nsISearchEngine with that domain.  If more than one
-   * engine has the same domain, the last one passed to _addEngine will be the
-   * one in this map.
+   * {Map<string: nsISearchEngine>} Maps from each domain to the engine with
+   * that domain.  If more than one engine has the same domain, the last one
+   * passed to _addEngine will be the one in this map.
    */
   enginesByDomain: new Map(),
 
   /**
-   * {Map} lowercased alias string => nsISearchEngine with that alias.  If more
-   * than one engine has the same alias, the last one passed to _addEngine will
-   * be the one in this map.
+   * {Map<string: nsISearchEngine>} Maps from each lowercased alias to the
+   * engine with that alias.  If more than one engine has the same alias, the
+   * last one passed to _addEngine will be the one in this map.
    */
   enginesByAlias: new Map(),
 
+  /**
+   * {array<{ {nsISearchEngine} engine, {array<string>} tokenAliases }>} Array
+   * of engines that have "@" aliases.
+   */
+  tokenAliasEngines: [],
+
   initialize() {
     return new Promise((resolve, reject) => {
       Services.search.init(status => {
         if (!Components.isSuccessCode(status)) {
           reject(new Error("Unable to initialize search service."));
         }
 
         try {
@@ -64,73 +70,79 @@ const SearchAutocompleteProviderInternal
       case "engine-current":
         this._refresh();
     }
   },
 
   _refresh() {
     this.enginesByDomain.clear();
     this.enginesByAlias.clear();
+    this.tokenAliasEngines = [];
 
     // The search engines will always be processed in the order returned by the
     // search service, which can be defined by the user.
     Services.search.getEngines().forEach(e => this._addEngine(e));
   },
 
   _addEngine(engine) {
     let domain = engine.getResultDomain();
     if (domain && !engine.hidden) {
       this.enginesByDomain.set(domain, engine);
     }
 
     let aliases = [];
     if (engine.alias) {
       aliases.push(engine.alias);
     }
-    aliases.push(...engine.wrappedJSObject._internalAliases);
+    let tokenAliases = engine.wrappedJSObject._internalAliases;
+    aliases.push(...tokenAliases);
     for (let alias of aliases) {
       this.enginesByAlias.set(alias.toLocaleLowerCase(), engine);
     }
+
+    if (tokenAliases.length) {
+      this.tokenAliasEngines.push({ engine, tokenAliases });
+    }
   },
 
   QueryInterface: ChromeUtils.generateQI([Ci.nsIObserver,
                                           Ci.nsISupportsWeakReference]),
 };
 
 class SuggestionsFetch {
   /**
    * Create a new instance of this class for each new suggestions fetch.
    *
    * @param {nsISearchEngine} engine
    *        The engine from which suggestions will be fetched.
-   * @param {string} searchToken
+   * @param {string} searchString
    *        The search query string.
    * @param {bool} inPrivateContext
    *        Pass true if the fetch is being done in a private window.
    * @param {int} maxLocalResults
    *        The maximum number of results to fetch from the user's local
    *        history.
    * @param {int} maxRemoteResults
    *        The maximum number of results to fetch from the search engine.
    * @param {int} userContextId
    *        The user context ID in which the fetch is being performed.
    */
   constructor(engine,
-              searchToken,
+              searchString,
               inPrivateContext,
               maxLocalResults,
               maxRemoteResults,
               userContextId) {
     this._controller = new SearchSuggestionController();
     this._controller.maxLocalResults = maxLocalResults;
     this._controller.maxRemoteResults = maxRemoteResults;
     this._engine = engine;
     this._suggestions = [];
     this._success = false;
-    this._promise = this._controller.fetch(searchToken, inPrivateContext, engine, userContextId).then(results => {
+    this._promise = this._controller.fetch(searchString, inPrivateContext, engine, userContextId).then(results => {
       this._success = true;
       if (results) {
         this._suggestions.push(
           ...results.local.map(r => ({ suggestion: r, historical: true })),
           ...results.remote.map(r => ({ suggestion: r, historical: false }))
         );
       }
     }).catch(err => {
@@ -231,16 +243,28 @@ var PlacesSearchAutocompleteProvider = O
   async engineForAlias(alias) {
     await this.ensureInitialized();
 
     return SearchAutocompleteProviderInternal
            .enginesByAlias.get(alias.toLocaleLowerCase()) || null;
   },
 
   /**
+   * Gets the list of engines with token ("@") aliases.
+   *
+   * @returns {array<{ {nsISearchEngine} engine, {array<string>} tokenAliases }>}
+   *          Array of objects { engine, tokenAliases } for token alias engines.
+   */
+  async tokenAliasEngines() {
+    await this.ensureInitialized();
+
+    return SearchAutocompleteProviderInternal.tokenAliasEngines.slice();
+  },
+
+  /**
    * Use this to get the current engine rather than Services.search.currentEngine
    * directly.  This method makes sure that the service is first initialized.
    *
    * @returns {nsISearchEngine} The current search engine.
    */
   async currentEngine() {
     await this.ensureInitialized();
 
@@ -279,39 +303,39 @@ var PlacesSearchAutocompleteProvider = O
     };
   },
 
   /**
    * Starts a new suggestions fetch.
    *
    * @param   {nsISearchEngine} engine
    *          The engine from which suggestions will be fetched.
-   * @param   {string} searchToken
+   * @param   {string} searchString
    *          The search query string.
    * @param   {bool} inPrivateContext
    *          Pass true if the fetch is being done in a private window.
    * @param   {int} maxLocalResults
    *          The maximum number of results to fetch from the user's local
    *          history.
    * @param   {int} maxRemoteResults
    *          The maximum number of results to fetch from the search engine.
    * @param   {int} userContextId
    *          The user context ID in which the fetch is being performed.
    * @returns {SuggestionsFetch} A new suggestions fetch object you should use
    *          to track the fetch.
    */
   newSuggestionsFetch(engine,
-                      searchToken,
+                      searchString,
                       inPrivateContext,
                       maxLocalResults,
                       maxRemoteResults,
                       userContextId) {
     if (!SearchAutocompleteProviderInternal.initialized) {
       throw new Error("The component has not been initialized.");
     }
     if (!engine) {
       throw new Error("`engine` is null");
     }
-    return new SuggestionsFetch(engine, searchToken, inPrivateContext,
+    return new SuggestionsFetch(engine, searchString, inPrivateContext,
                                 maxLocalResults, maxRemoteResults,
                                 userContextId);
   },
 });
--- a/toolkit/components/places/UnifiedComplete.js
+++ b/toolkit/components/places/UnifiedComplete.js
@@ -807,16 +807,27 @@ Search.prototype = {
     // expected to auto-select the first result when actions are enabled. If the
     // first result is an inline completion result, that will also be the
     // default result and therefore be autofilled (this also happens if actions
     // are not enabled).
 
     // Check for Preloaded Sites Expiry before Autofill
     await this._checkPreloadedSitesExpiry();
 
+    // If the query is simply "@", then the results should be a list of all the
+    // search engines with "@" aliases, without a hueristic result.
+    if (this._trimmedOriginalSearchString == "@") {
+      let added = await this._addSearchEngineTokenAliasResults();
+      if (added) {
+        this._cleanUpNonCurrentMatches(null);
+        this._autocompleteSearch.finishSearch(true);
+        return;
+      }
+    }
+
     // Add the first heuristic result, if any.  Set _addingHeuristicFirstMatch
     // to true so that when the result is added, "heuristic" can be included in
     // its style.
     this._addingHeuristicFirstMatch = true;
     let hasHeuristic = await this._matchFirstHeuristicResult(conn);
     this._addingHeuristicFirstMatch = false;
     this._cleanUpNonCurrentMatches(UrlbarUtils.MATCH_GROUP.HEURISTIC);
     if (!this.pending)
@@ -881,17 +892,17 @@ Search.prototype = {
           searchSuggestionsCompletePromise =
             this._matchSearchSuggestions(engine, query);
           // If the user has used a search engine alias, then the only results
           // we want to show are suggestions from that engine, so we're done.
           // We're also done if we're restricting results to suggestions.
           if (this._searchEngineAliasMatch || this.hasBehavior("restrict")) {
             // Wait for the suggestions to be added.
             await searchSuggestionsCompletePromise;
-            this._cleanUpNonCurrentMatches(UrlbarUtils.MATCH_GROUP.SUGGESTION);
+            this._cleanUpNonCurrentMatches(null);
             this._autocompleteSearch.finishSearch(true);
             return;
           }
         }
       }
     }
     // In any case, clear previous suggestions.
     searchSuggestionsCompletePromise.then(() => {
@@ -1067,16 +1078,40 @@ Search.prototype = {
       value,
       url,
       Infinity,
       ["preloaded-top-site"]
     );
     return true;
   },
 
+  /**
+   * Adds matches for all the engines with "@" aliases, if any.
+   *
+   * @returns {bool} True if any results were added, false if not.
+   */
+  async _addSearchEngineTokenAliasResults() {
+    let engines = await PlacesSearchAutocompleteProvider.tokenAliasEngines();
+    if (!engines || !engines.length) {
+      return false;
+    }
+    for (let { engine, tokenAliases } of engines) {
+      let alias = tokenAliases[0];
+      // `input` should have a trailing space so that when the user selects the
+      // result, they can start typing their query without first having to enter
+      // a space between the alias and query.
+      this._addSearchEngineMatch({
+        engine,
+        alias,
+        input: alias + " ",
+      });
+    }
+    return true;
+  },
+
   async _matchFirstHeuristicResult(conn) {
     // We always try to make the first result a special "heuristic" result.  The
     // heuristics below determine what type of result it will be, if any.
 
     let hasSearchTerms = this._searchTokens.length > 0;
 
     if (hasSearchTerms) {
       // It may be a keyword registered by an extension.
@@ -1393,21 +1428,20 @@ Search.prototype = {
     }
 
     let alias = this._searchTokens[0];
     let engine = await PlacesSearchAutocompleteProvider.engineForAlias(alias);
     if (!engine) {
       return false;
     }
 
-    let query = this._trimmedOriginalSearchString.substr(alias.length + 1);
     this._searchEngineAliasMatch = {
       engine,
-      query,
       alias,
+      query: this._trimmedOriginalSearchString.substr(alias.length + 1),
     };
     this._addSearchEngineMatch(this._searchEngineAliasMatch);
     if (!this._keywordSubstitute) {
       this._keywordSubstitute = engine.getResultDomain();
     }
     return true;
   },
 
@@ -1443,40 +1477,47 @@ Search.prototype = {
     });
   },
 
   /**
    * Adds a search engine match.
    *
    * @param {nsISearchEngine} engine
    *        The search engine associated with the match.
-   * @param {string} query
+   * @param {string} [query]
    *        The search query string.
    * @param {string} [alias]
-   *        The search engine alias associated with the match.
+   *        The search engine alias associated with the match, if any.
    * @param {string} [suggestion]
-   *        The suggestion from the search engine.
+   *        The suggestion from the search engine, if you're adding a suggestion
+   *        match.
    * @param {bool} [historical]
-   *        True if the suggestion is from the user's local history.
+   *        True if you're adding a suggestion match and the suggestion is from
+   *        the user's local history (and not the search engine).
+   * @param {string} [input]
+   *        Use this value to override the action.params.input that this method
+   *        would otherwise compute.
    */
   _addSearchEngineMatch({engine,
-                         query,
-                         alias,
-                         suggestion,
-                         historical}) {
+                         query = "",
+                         alias = undefined,
+                         suggestion = undefined,
+                         historical = false,
+                         input = undefined}) {
     let actionURLParams = {
       engineName: engine.name,
-      input: suggestion || this._originalSearchString,
+      input: input || suggestion || this._originalSearchString,
       searchQuery: query,
     };
-    if (suggestion)
-      actionURLParams.searchSuggestion = suggestion;
     if (alias) {
       actionURLParams.alias = alias;
     }
+    if (suggestion) {
+      actionURLParams.searchSuggestion = suggestion;
+    }
     let value = PlacesUtils.mozActionURI("searchengine", actionURLParams);
     let match = {
       value,
       comment: engine.name,
       icon: engine.iconURI ? engine.iconURI.spec : null,
       style: "action searchengine",
       frecency: FRECENCY_DEFAULT,
     };
--- a/toolkit/components/places/tests/unifiedcomplete/test_search_engine_alias.js
+++ b/toolkit/components/places/tests/unifiedcomplete/test_search_engine_alias.js
@@ -161,8 +161,44 @@ add_task(async function engineWithSugges
           searchSuggestion: "fire bar",
         }),
       ],
     });
   }
 
   await cleanup();
 });
+
+
+// When the search is simply "@", the results should be a list of all the "@"
+// alias engines.
+add_task(async function tokenAliasEngines() {
+  let tokenEngines = [];
+  for (let engine of Services.search.getEngines()) {
+    let tokenAliases = engine.wrappedJSObject._internalAliases;
+    if (tokenAliases.length) {
+      tokenEngines.push({ engine, tokenAliases });
+    }
+  }
+
+  if (!tokenEngines.length) {
+    Assert.ok(true, "No token alias engines, skipping task.");
+    return;
+  }
+
+  info("Got token alias engines: " +
+       tokenEngines.map(({ engine }) => engine.name));
+
+  await check_autocomplete({
+    search: "@",
+    searchParam: "enable-actions",
+    matches: tokenEngines.map(({ engine, tokenAliases }) => {
+      let alias = tokenAliases[0];
+      return makeSearchMatch(alias + " ", {
+        engineName: engine.name,
+        alias,
+        searchQuery: "",
+      });
+    }),
+  });
+
+  await cleanup();
+});
--- a/toolkit/content/widgets/autocomplete.xml
+++ b/toolkit/content/widgets/autocomplete.xml
@@ -1807,17 +1807,21 @@
             // Treat autofills as visiturl actions.
             action = {
               type: "visiturl",
               params: { url: title },
             };
           }
 
           this.removeAttribute("actiontype");
-          this.classList.remove("overridable-action", "emptySearchQuery");
+          this.classList.remove(
+            "overridable-action",
+            "emptySearchQuery",
+            "aliasOffer"
+          );
 
           // If the type includes an action, set up the item appropriately.
           if (initialTypes.has("action") || action) {
             action = action || this._parseActionUrl(originalUrl);
             this.setAttribute("actiontype", action.type);
 
             switch (action.type) {
             case "switchtab": {
@@ -1836,17 +1840,22 @@
             case "searchengine": {
               emphasiseUrl = false;
 
               // The order here is not localizable, we default to appending
               // "- Search with Engine" to the search string, to be able to
               // properly generate emphasis pairs. That said, no localization
               // changed the order while it was possible, so doesn't look like
               // there's a strong need for that.
-              let {engineName, searchSuggestion, searchQuery} = action.params;
+              let {
+                engineName,
+                searchSuggestion,
+                searchQuery,
+                alias,
+              } = action.params;
 
               // Override the engine name if the popup defines an override.
               let override = popup.overrideSearchEngineName;
               if (override && override != engineName) {
                 engineName = override;
                 action.params.engineName = override;
                 let newURL =
                   PlacesUtils.mozActionURI(action.type, action.params);
@@ -1873,16 +1882,29 @@
                     [searchQuery, "match"],
                     [searchSuggestion.substring(idx + searchQuery.length), ""],
                   ];
                 } else {
                   pairs = [
                     [searchSuggestion, ""],
                   ];
                 }
+              } else if (alias &&
+                         !searchQuery.trim() &&
+                         !initialTypes.has("heuristic")) {
+                // For non-heuristic alias results that have an empty query, we
+                // want to show "@engine -- Search with Engine" to make it clear
+                // that the user can search by selecting the result and using
+                // the alias.  Normally we hide the "Search with Engine" part
+                // until the result is selected or moused over, but not here.
+                // Add the aliasOffer class so we can detect this in the CSS.
+                this.classList.add("aliasOffer");
+                pairs = [
+                  [alias, ""],
+                ];
               } else {
                 // Add the emptySearchQuery class if the search query is the
                 // empty string.  We use it to hide .ac-separator in CSS.
                 if (!searchQuery.trim()) {
                   this.classList.add("emptySearchQuery");
                 }
                 pairs = [
                   [searchQuery, ""],