gecko-dev/browser/components/urlbar/UrlbarController.jsm
Drew Willcoxon ee7630f333 Bug 1524718 - Replace context.autofillValue with result.autofill, and autofill results when they're selected. r=mak
We should replace the context.autofillValue property with a result.autofill property. When the view selects results, it already notifies the input about it by calling input.setValueFromResult(). So we can modify setValueFromResult to check for the presence of result.autofill and thereby get autofill "for free".

result.autofill is an object: { value, selectionStart, selectionEnd }

This is going to help me implement bug 1521702.

One potentially cool thing about doing autofill this way is that any result can now trigger autofill, not only the heuristic result, and do it easily. Of course the user isn't typing when they select a non-heuristic result, so it's probably not fair to call that "autofill", but the result can trigger the selection aspect of autofill. As one example, that might be interesting for search suggestions: Type "foo", key down to the "foobar" suggestion, and the "bar" substring is automatically selected.

Differential Revision: https://phabricator.services.mozilla.com/D18618

--HG--
extra : moz-landing-system : lando
2019-02-07 00:30:04 +00:00

349 lines
11 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
var EXPORTED_SYMBOLS = [
"UrlbarController",
];
const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm");
XPCOMUtils.defineLazyModuleGetters(this, {
AppConstants: "resource://gre/modules/AppConstants.jsm",
// BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.jsm",
PlacesUtils: "resource://gre/modules/PlacesUtils.jsm",
UrlbarPrefs: "resource:///modules/UrlbarPrefs.jsm",
UrlbarProvidersManager: "resource:///modules/UrlbarProvidersManager.jsm",
UrlbarUtils: "resource:///modules/UrlbarUtils.jsm",
});
const TELEMETRY_1ST_RESULT = "PLACES_AUTOCOMPLETE_1ST_RESULT_TIME_MS";
const TELEMETRY_6_FIRST_RESULTS = "PLACES_AUTOCOMPLETE_6_FIRST_RESULTS_TIME_MS";
/**
* The address bar controller handles queries from the address bar, obtains
* results and returns them to the UI for display.
*
* Listeners may be added to listen for the results. They must support the
* following methods which may be called when a query is run:
*
* - onQueryStarted(queryContext)
* - onQueryResults(queryContext)
* - onQueryCancelled(queryContext)
* - onQueryFinished(queryContext)
* - onQueryResultRemoved(index)
*/
class UrlbarController {
/**
* Initialises the class. The manager may be overridden here, this is for
* test purposes.
*
* @param {object} options
* The initial options for UrlbarController.
* @param {object} options.browserWindow
* The browser window this controller is operating within.
* @param {object} [options.manager]
* Optional fake providers manager to override the built-in providers manager.
* Intended for use in unit tests only.
*/
constructor(options = {}) {
if (!options.browserWindow) {
throw new Error("Missing options: browserWindow");
}
if (!options.browserWindow.location ||
options.browserWindow.location.href != AppConstants.BROWSER_CHROME_URL) {
throw new Error("browserWindow should be an actual browser window.");
}
this.manager = options.manager || UrlbarProvidersManager;
this.browserWindow = options.browserWindow;
this._listeners = new Set();
}
/**
* Hooks up the controller with an input.
*
* @param {UrlbarInput} input
* The UrlbarInput instance associated with this controller.
*/
setInput(input) {
this.input = input;
}
/**
* Hooks up the controller with a view.
*
* @param {UrlbarView} view
* The UrlbarView instance associated with this controller.
*/
setView(view) {
this.view = view;
}
/**
* Takes a query context and starts the query based on the user input.
*
* @param {UrlbarQueryContext} queryContext The query details.
*/
async startQuery(queryContext) {
// Cancel any running query.
if (this._lastQueryContext) {
this.cancelQuery(this._lastQueryContext);
}
this._lastQueryContext = queryContext;
queryContext.lastResultCount = 0;
TelemetryStopwatch.start(TELEMETRY_1ST_RESULT, queryContext);
TelemetryStopwatch.start(TELEMETRY_6_FIRST_RESULTS, queryContext);
this._notify("onQueryStarted", queryContext);
await this.manager.startQuery(queryContext, this);
this._notify("onQueryFinished", queryContext);
return queryContext;
}
/**
* Cancels an in-progress query. Note, queries may continue running if they
* can't be canceled.
*/
cancelQuery() {
if (!this._lastQueryContext) {
return;
}
TelemetryStopwatch.cancel(TELEMETRY_1ST_RESULT, this._lastQueryContext);
TelemetryStopwatch.cancel(TELEMETRY_6_FIRST_RESULTS, this._lastQueryContext);
this.manager.cancelQuery(this._lastQueryContext);
this._notify("onQueryCancelled", this._lastQueryContext);
delete this._lastQueryContext;
}
/**
* Receives results from a query.
*
* @param {UrlbarQueryContext} queryContext The query details.
*/
receiveResults(queryContext) {
if (queryContext.lastResultCount < 1 && queryContext.results.length >= 1) {
TelemetryStopwatch.finish(TELEMETRY_1ST_RESULT, queryContext);
}
if (queryContext.lastResultCount < 6 && queryContext.results.length >= 6) {
TelemetryStopwatch.finish(TELEMETRY_6_FIRST_RESULTS, queryContext);
}
if (queryContext.lastResultCount == 0) {
if (queryContext.results.length && queryContext.results[0].autofill) {
this.input.setValueFromResult(queryContext.results[0]);
}
// The first time we receive results try to connect to the heuristic
// result.
this.speculativeConnect(queryContext, 0, "resultsadded");
}
this._notify("onQueryResults", queryContext);
// Update lastResultCount after notifying, so the view can use it.
queryContext.lastResultCount = queryContext.results.length;
}
/**
* Adds a listener for query actions and results.
*
* @param {object} listener The listener to add.
* @throws {TypeError} Throws if the listener is not an object.
*/
addQueryListener(listener) {
if (!listener || typeof listener != "object") {
throw new TypeError("Expected listener to be an object");
}
this._listeners.add(listener);
}
/**
* Removes a query listener.
*
* @param {object} listener The listener to add.
*/
removeQueryListener(listener) {
this._listeners.delete(listener);
}
/**
* When switching tabs, clear some internal caches to handle cases like
* backspace, autofill or repeated searches.
*/
tabContextChanged() {
// TODO: implementation needed (bug 1496685)
}
/**
* Receives keyboard events from the input and handles those that should
* navigate within the view or pick the currently selected item.
*
* @param {KeyboardEvent} event
* The DOM KeyboardEvent.
*/
handleKeyNavigation(event) {
const isMac = AppConstants.platform == "macosx";
// Handle readline/emacs-style navigation bindings on Mac.
if (isMac &&
this.view.isOpen &&
event.ctrlKey &&
(event.key == "n" || event.key == "p")) {
this.view.selectNextItem({ reverse: event.key == "p" });
event.preventDefault();
return;
}
switch (event.keyCode) {
case KeyEvent.DOM_VK_ESCAPE:
this.input.handleRevert();
event.preventDefault();
break;
case KeyEvent.DOM_VK_RETURN:
if (isMac &&
event.metaKey) {
// Prevent beep on Mac.
event.preventDefault();
}
// TODO: We should have an input bufferrer so that we can use search results
// if appropriate.
this.input.handleCommand(event);
break;
case KeyEvent.DOM_VK_TAB:
if (this.view.isOpen) {
this.view.selectNextItem({ reverse: event.shiftKey });
event.preventDefault();
}
break;
case KeyEvent.DOM_VK_DOWN:
case KeyEvent.DOM_VK_UP:
if (!event.ctrlKey && !event.altKey) {
if (this.view.isOpen) {
this.view.selectNextItem({
reverse: event.keyCode == KeyEvent.DOM_VK_UP });
} else {
this.input.startQuery();
}
event.preventDefault();
}
break;
case KeyEvent.DOM_VK_DELETE:
if (isMac && !event.shiftKey) {
break;
}
if (this._handleDeleteEntry()) {
event.preventDefault();
}
break;
case KeyEvent.DOM_VK_BACK_SPACE:
if (isMac && event.shiftKey &&
this._handleDeleteEntry()) {
event.preventDefault();
}
break;
}
}
/**
* Tries to initialize a speculative connection on a result.
* Speculative connections are only supported for a subset of all the results.
* @param {UrlbarQueryContext} context The queryContext
* @param {number} resultIndex index of the result to speculative connect to.
* @param {string} reason Reason for the speculative connect request.
* @note speculative connect to:
* - Search engine heuristic results
* - autofill results
* - http/https results
*/
speculativeConnect(context, resultIndex, reason) {
// Never speculative connect in private contexts.
if (!this.input || context.isPrivate || context.results.length == 0) {
return;
}
let result = context.results[resultIndex];
let {url} = UrlbarUtils.getUrlFromResult(result);
if (!url) {
return;
}
switch (reason) {
case "resultsadded": {
// We should connect to an heuristic result, if it exists.
if ((resultIndex == 0 && context.preselected) || result.autofill) {
if (result.type == UrlbarUtils.RESULT_TYPE.SEARCH) {
// Speculative connect only if search suggestions are enabled.
if (UrlbarPrefs.get("suggest.searches") &&
UrlbarPrefs.get("browser.search.suggest.enabled")) {
let engine = Services.search.defaultEngine;
UrlbarUtils.setupSpeculativeConnection(engine, this.browserWindow);
}
} else if (result.autofill) {
UrlbarUtils.setupSpeculativeConnection(url, this.browserWindow);
}
}
return;
}
case "mousedown": {
// On mousedown, connect only to http/https urls.
if (url.startsWith("http")) {
UrlbarUtils.setupSpeculativeConnection(url, this.browserWindow);
}
return;
}
default: {
throw new Error("Invalid speculative connection reason");
}
}
}
/**
* Internal function handling deletion of entries. We only support removing
* of history entries - other result sources will be ignored.
*
* @returns {boolean} Returns true if the deletion was acted upon.
*/
_handleDeleteEntry() {
if (!this._lastQueryContext) {
Cu.reportError("Cannot delete - the latest query is not present");
return false;
}
const selectedResult = this.input.view.selectedResult;
if (!selectedResult ||
selectedResult.source != UrlbarUtils.RESULT_SOURCE.HISTORY) {
return false;
}
let index = this._lastQueryContext.results.indexOf(selectedResult);
if (!index) {
Cu.reportError("Failed to find the selected result in the results");
return false;
}
this._lastQueryContext.results.splice(index, 1);
this._notify("onQueryResultRemoved", index);
PlacesUtils.history.remove(selectedResult.payload.url).catch(Cu.reportError);
return true;
}
/**
* Internal function to notify listeners of results.
*
* @param {string} name Name of the notification.
* @param {object} params Parameters to pass with the notification.
*/
_notify(name, ...params) {
for (let listener of this._listeners) {
try {
listener[name](...params);
} catch (ex) {
Cu.reportError(ex);
}
}
}
}