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 500675 eab3a0cf64298bbe275a6598bea9ae73811652db
parent 500674 68445fa636530442c2ac79c80f506e86ccaa838d
child 500676 418fa1fa4f746f82b0a1a070ef9dfb12849b5281
push id1864
push userffxbld-merge
push dateMon, 03 Dec 2018 15:51:40 +0000
treeherdermozilla-release@f040763d99ad [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmak
bugs1496815, 1496814, 1496811
milestone64.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 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, ""],