diff --git a/src/common/string/sequence_matching.ts b/src/common/string/sequence_matching.ts
new file mode 100644
index 0000000000..3898ec667f
--- /dev/null
+++ b/src/common/string/sequence_matching.ts
@@ -0,0 +1,29 @@
+/**
+ * Determine whether a sequence of letters exists in another string,
+ * in that order, allowing for skipping. Ex: "chdr" exists in "chandelier")
+ *
+ * filter => sequence of letters
+ * word => Word to check for sequence
+ *
+ * return true if word contains sequence. Otherwise false.
+ */
+export const fuzzySequentialMatch = (filter: string, word: string) => {
+ if (filter === "") {
+ return true;
+ }
+
+ for (let i = 0; i <= filter.length; i++) {
+ const pos = word.indexOf(filter[0]);
+
+ if (pos < 0) {
+ return false;
+ }
+
+ const newWord = word.substring(pos + 1);
+ const newFilter = filter.substring(1);
+
+ return fuzzySequentialMatch(newFilter, newWord);
+ }
+
+ return true;
+};
diff --git a/src/dialogs/quick-open/ha-quick-open-dialog.ts b/src/dialogs/quick-open/ha-quick-open-dialog.ts
new file mode 100644
index 0000000000..8eebf70457
--- /dev/null
+++ b/src/dialogs/quick-open/ha-quick-open-dialog.ts
@@ -0,0 +1,225 @@
+import "../../components/ha-header-bar";
+import "@polymer/paper-input/paper-input";
+import "@material/mwc-list/mwc-list-item";
+import "@material/mwc-list/mwc-list";
+import {
+ css,
+ customElement,
+ html,
+ internalProperty,
+ LitElement,
+ property,
+} from "lit-element";
+import { fireEvent } from "../../common/dom/fire_event";
+import "../../components/ha-dialog";
+import { haStyleDialog } from "../../resources/styles";
+import { HomeAssistant, ServiceCallRequest } from "../../types";
+import { PolymerChangedEvent } from "../../polymer-types";
+import { fuzzySequentialMatch } from "../../common/string/sequence_matching";
+import { componentsWithService } from "../../common/config/components_with_service";
+import { domainIcon } from "../../common/entity/domain_icon";
+import { computeDomain } from "../../common/entity/compute_domain";
+import { domainToName } from "../../data/integration";
+import { QuickOpenDialogParams } from "./show-dialog-quick-open";
+import { HassEntity } from "home-assistant-js-websocket";
+import { compare } from "../../common/string/compare";
+import memoizeOne from "memoize-one";
+
+interface CommandItem extends ServiceCallRequest {
+ text: string;
+}
+
+@customElement("ha-quick-open-dialog")
+export class QuickOpenDialog extends LitElement {
+ @property({ attribute: false }) public hass!: HomeAssistant;
+
+ @internalProperty() private _commandItems: CommandItem[] = [];
+
+ @internalProperty() private _itemFilter = "";
+
+ @internalProperty() private _opened = false;
+
+ @internalProperty() private _commandMode = false;
+
+ public async showDialog(params: QuickOpenDialogParams) {
+ this._commandMode = params.commandMode || false;
+ this._opened = true;
+ this._commandItems = this._generateCommandItems();
+ }
+
+ public closeDialog() {
+ this._opened = false;
+ this._itemFilter = "";
+ fireEvent(this, "dialog-closed", { dialog: this.localName });
+ }
+
+ protected render() {
+ if (!this._opened) {
+ return html``;
+ }
+
+ return html`
+
+ ${this._itemFilter}` : this._itemFilter}
+ >
+
+ ${this._commandMode
+ ? this.renderCommandsList(this._itemFilter)
+ : this.renderEntityList(this._itemFilter)}
+
+
+ `;
+ }
+
+ protected renderCommandsList = memoizeOne((filter) => {
+ const items = this._filterCommandItems(this._commandItems, filter);
+
+ return html`
+ ${items.map(
+ ({ text, domain, service, serviceData }) => html`
+
+
+ ${text}
+
+ `
+ )}
+ `;
+ });
+
+ protected renderEntityList = memoizeOne((filter) => {
+ const entities = this._filterEntityItems(
+ Object.keys(this.hass.states),
+ filter
+ );
+
+ return html`
+ ${entities.map((entity_id) => {
+ const domain = computeDomain(entity_id);
+ return html`
+
+
+ ${entity_id}
+
+ `;
+ })}
+ `;
+ });
+
+ private _entityFilterChanged(ev: PolymerChangedEvent) {
+ const newFilter = ev.detail.value;
+
+ if (newFilter.startsWith(">")) {
+ this._commandMode = true;
+ this._itemFilter = newFilter.substring(1);
+ } else {
+ this._commandMode = false;
+ this._itemFilter = newFilter;
+ }
+ }
+
+ private _generateCommandItems(): CommandItem[] {
+ const reloadableDomains = componentsWithService(this.hass, "reload").sort();
+
+ return reloadableDomains.map((domain) => ({
+ text:
+ this.hass.localize(`ui.dialogs.quick-open.commands.reload.${domain}`) ||
+ this.hass.localize(
+ "ui.dialogs.quick-open.commands.reload.reload",
+ "domain",
+ domainToName(this.hass.localize, domain)
+ ),
+ domain,
+ service: "reload",
+ }));
+ }
+
+ private _filterCommandItems(
+ items: CommandItem[],
+ filter: string
+ ): CommandItem[] {
+ return items
+ .filter(({ text }) =>
+ fuzzySequentialMatch(filter.toLowerCase(), text.toLowerCase())
+ )
+ .sort((itemA, itemB) => compare(itemA.text, itemB.text));
+ }
+
+ private _filterEntityItems(
+ entity_ids: HassEntity["entity_id"][],
+ filter: string
+ ): HassEntity["entity_id"][] {
+ return entity_ids
+ .filter((entity_id) =>
+ fuzzySequentialMatch(filter.toLowerCase(), entity_id)
+ )
+ .sort();
+ }
+
+ private async _executeCommand(ev: Event) {
+ const target = ev.currentTarget as any;
+
+ await this.hass.callService(
+ target.domain,
+ target.service,
+ target.serviceData
+ );
+
+ this.closeDialog();
+ }
+
+ private _entityMoreInfo(ev: Event) {
+ ev.preventDefault();
+ fireEvent(this, "hass-more-info", {
+ entityId: (ev.target as any).text,
+ });
+ this.closeDialog();
+ }
+
+ static get styles() {
+ return [
+ haStyleDialog,
+ css`
+ .heading {
+ padding: 20px 20px 0px;
+ }
+
+ ha-dialog {
+ --dialog-z-index: 8;
+ --dialog-content-padding: 0px 24px 20px;
+ }
+
+ @media (min-width: 800px) {
+ ha-dialog {
+ --mdc-dialog-max-width: 800px;
+ --mdc-dialog-min-width: 500px;
+ --dialog-surface-position: fixed;
+ --dialog-surface-top: 40px;
+ --mdc-dialog-max-height: calc(100% - 72px);
+ }
+ }
+ `,
+ ];
+ }
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "ha-quick-open-dialog": QuickOpenDialog;
+ }
+}
diff --git a/src/dialogs/quick-open/show-dialog-quick-open.ts b/src/dialogs/quick-open/show-dialog-quick-open.ts
new file mode 100644
index 0000000000..10b3b1932f
--- /dev/null
+++ b/src/dialogs/quick-open/show-dialog-quick-open.ts
@@ -0,0 +1,20 @@
+import { fireEvent } from "../../common/dom/fire_event";
+
+export interface QuickOpenDialogParams {
+ entityFilter?: string;
+ commandMode?: boolean;
+}
+
+export const loadQuickOpenDialog = () =>
+ import(/* webpackChunkName: "quick-open-dialog" */ "./ha-quick-open-dialog");
+
+export const showQuickOpenDialog = (
+ element: HTMLElement,
+ dialogParams: QuickOpenDialogParams
+): void => {
+ fireEvent(element, "show-dialog", {
+ dialogTag: "ha-quick-open-dialog",
+ dialogImport: loadQuickOpenDialog,
+ dialogParams,
+ });
+};
diff --git a/src/layouts/home-assistant.ts b/src/layouts/home-assistant.ts
index b1f95abd52..1fa77f2670 100644
--- a/src/layouts/home-assistant.ts
+++ b/src/layouts/home-assistant.ts
@@ -17,9 +17,10 @@ import {
import "./ha-init-page";
import "./home-assistant-main";
import { storeState } from "../util/ha-pref-storage";
+import QuickOpenMixin from "../state/quick-open-mixin";
@customElement("home-assistant")
-export class HomeAssistantAppEl extends HassElement {
+export class HomeAssistantAppEl extends QuickOpenMixin(HassElement) {
@internalProperty() private _route?: Route;
@internalProperty() private _error = false;
diff --git a/src/state/quick-open-mixin.ts b/src/state/quick-open-mixin.ts
new file mode 100644
index 0000000000..73856a03cc
--- /dev/null
+++ b/src/state/quick-open-mixin.ts
@@ -0,0 +1,31 @@
+import type { Constructor, PropertyValues } from "lit-element";
+import { HassElement } from "./hass-element";
+import {
+ QuickOpenDialogParams,
+ showQuickOpenDialog,
+} from "../dialogs/quick-open/show-dialog-quick-open";
+
+declare global {
+ interface HASSDomEvents {
+ "hass-quick-open": QuickOpenDialogParams;
+ }
+}
+
+export default >(superClass: T) =>
+ class extends superClass {
+ protected firstUpdated(changedProps: PropertyValues) {
+ super.firstUpdated(changedProps);
+
+ document.addEventListener("keydown", (e: KeyboardEvent) => {
+ if (e.code === "KeyP" && e.metaKey) {
+ e.preventDefault();
+ const eventParams: QuickOpenDialogParams = {};
+ if (e.shiftKey) {
+ eventParams.commandMode = true;
+ }
+
+ showQuickOpenDialog(this, eventParams);
+ }
+ });
+ }
+ };
diff --git a/src/translations/en.json b/src/translations/en.json
index 7a62f3ab1b..ad917f2bae 100755
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -434,6 +434,44 @@
}
},
"dialogs": {
+ "quick-open": {
+ "commands": {
+ "reload": {
+ "reload": "[%key:ui::panel::config::server_control::section::reloading::reload%]",
+ "core": "[%key:ui::panel::config::server_control::section::reloading::core%]",
+ "group": "[%key:ui::panel::config::server_control::section::reloading::group%]",
+ "automation": "[%key:ui::panel::config::server_control::section::reloading::automation%]",
+ "script": "[%key:ui::panel::config::server_control::section::reloading::script%]",
+ "scene": "[%key:ui::panel::config::server_control::section::reloading::scene%]",
+ "person": "[%key:ui::panel::config::server_control::section::reloading::person%]",
+ "zone": "[%key:ui::panel::config::server_control::section::reloading::zone%]",
+ "input_boolean": "[%key:ui::panel::config::server_control::section::reloading::input_boolean%]",
+ "input_text": "[%key:ui::panel::config::server_control::section::reloading::input_text%]",
+ "input_number": "[%key:ui::panel::config::server_control::section::reloading::input_number%]",
+ "input_datetime": "[%key:ui::panel::config::server_control::section::reloading::input_datetime%]",
+ "input_select": "[%key:ui::panel::config::server_control::section::reloading::input_select%]",
+ "template": "[%key:ui::panel::config::server_control::section::reloading::template%]",
+ "universal": "[%key:ui::panel::config::server_control::section::reloading::universal%]",
+ "rest": "[%key:ui::panel::config::server_control::section::reloading::rest%]",
+ "command_line": "[%key:ui::panel::config::server_control::section::reloading::command_line%]",
+ "filter": "[%key:ui::panel::config::server_control::section::reloading::filter%]",
+ "statistics": "[%key:ui::panel::config::server_control::section::reloading::statistics%]",
+ "generic": "[%key:ui::panel::config::server_control::section::reloading::generic%]",
+ "generic_thermostat": "[%key:ui::panel::config::server_control::section::reloading::generic_thermostat%]",
+ "homekit": "[%key:ui::panel::config::server_control::section::reloading::homekit%]",
+ "min_max": "[%key:ui::panel::config::server_control::section::reloading::min_max%]",
+ "history_stats": "[%key:ui::panel::config::server_control::section::reloading::history_stats%]",
+ "trend": "[%key:ui::panel::config::server_control::section::reloading::trend%]",
+ "ping": "[%key:ui::panel::config::server_control::section::reloading::ping%]",
+ "filesize": "[%key:ui::panel::config::server_control::section::reloading::filesize%]",
+ "telegram": "[%key:ui::panel::config::server_control::section::reloading::telegram%]",
+ "smtp": "[%key:ui::panel::config::server_control::section::reloading::smtp%]",
+ "mqtt": "[%key:ui::panel::config::server_control::section::reloading::mqtt%]",
+ "rpi_gpio": "[%key:ui::panel::config::server_control::section::reloading::rpi_gpio%]"
+ }
+ },
+ "filter_placeholder": "Entity Filter"
+ },
"voice_command": {
"did_not_hear": "Home Assistant did not hear anything",
"found": "I found the following for you:",
diff --git a/src/types.ts b/src/types.ts
index c6b90056ea..c277a60093 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -206,6 +206,12 @@ export interface ServiceCallResponse {
context: Context;
}
+export interface ServiceCallRequest {
+ domain: string;
+ service: string;
+ serviceData?: { [key: string]: any };
+}
+
export interface HomeAssistant {
auth: Auth & { external?: ExternalMessaging };
connection: Connection;
@@ -239,9 +245,9 @@ export interface HomeAssistant {
userData?: CoreFrontendUserData | null;
hassUrl(path?): string;
callService(
- domain: string,
- service: string,
- serviceData?: { [key: string]: any }
+ domain: ServiceCallRequest["domain"],
+ service: ServiceCallRequest["service"],
+ serviceData?: ServiceCallRequest["serviceData"]
): Promise;
callApi(
method: "GET" | "POST" | "PUT" | "DELETE",