Compare commits

...

5 Commits

Author SHA1 Message Date
Zack
0dfd55e773 comit 2022-04-19 07:56:59 -05:00
Zack
976d3ddc87 Lint 2022-04-15 23:36:38 -05:00
Zack
6d3d194f42 Add area, device and entity registry 2022-04-15 23:06:17 -05:00
Zack
43246029a1 Initial Implementation 2022-04-15 14:37:31 -05:00
Zack
fc06b9c29d Stash 2022-04-15 14:34:38 -05:00
4 changed files with 565 additions and 500 deletions

278
src/data/quick-bar.ts Normal file
View File

@@ -0,0 +1,278 @@
import {
mdiEarth,
mdiNavigationVariantOutline,
mdiReload,
mdiServerNetwork,
} from "@mdi/js";
import { canShowPage } from "../common/config/can_show_page";
import { componentsWithService } from "../common/config/components_with_service";
import { computeDomain } from "../common/entity/compute_domain";
import { computeStateDisplay } from "../common/entity/compute_state_display";
import { computeStateName } from "../common/entity/compute_state_name";
import { domainIcon } from "../common/entity/domain_icon";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import { ScorableTextItem } from "../common/string/filter/sequence-matching";
import { PageNavigation } from "../layouts/hass-tabs-subpage";
import { configSections } from "../panels/config/ha-panel-config";
import { HomeAssistant } from "../types";
import { AreaRegistryEntry } from "./area_registry";
import { computeDeviceName, DeviceRegistryEntry } from "./device_registry";
import { EntityRegistryEntry } from "./entity_registry";
import { domainToName } from "./integration";
import { getPanelNameTranslationKey } from "./panel";
export interface QuickBarItem extends ScorableTextItem {
primaryText: string;
primaryTextAlt?: string;
secondaryText?: string;
metaText?: string;
categoryKey:
| "reload"
| "navigation"
| "server_control"
| "entity"
| "suggestion";
actionData: string | string[];
iconPath?: string;
icon?: string;
path?: string;
isSuggestion?: boolean;
}
export type NavigationInfo = PageNavigation &
Pick<QuickBarItem, "primaryText" | "secondaryText">;
export type BaseNavigationCommand = Pick<QuickBarItem, "primaryText" | "path">;
export const generateEntityItems = (
hass: HomeAssistant,
entities: { [entityId: string]: EntityRegistryEntry },
devices: { [deviceId: string]: DeviceRegistryEntry },
areas: { [areaId: string]: AreaRegistryEntry }
): QuickBarItem[] =>
Object.keys(hass.states)
.map((entityId) => {
const entityState = hass.states[entityId];
const entity = entities[entityId];
const deviceName = entity?.device_id
? computeDeviceName(devices[entity.device_id], hass)
: undefined;
const entityItem = {
primaryText: computeStateName(entityState),
primaryTextAlt: computeStateDisplay(
hass.localize,
entityState,
hass.locale
),
secondaryText:
(deviceName ? `${deviceName} | ` : "") +
(hass.userData?.showAdvanced ? entityId : ""),
metaText: entity?.area_id ? areas[entity.area_id].name : undefined,
icon: entityState.attributes.icon,
iconPath: entityState.attributes.icon
? undefined
: domainIcon(computeDomain(entityId), entityState),
actionData: entityId,
categoryKey: "entity" as const,
};
return {
...entityItem,
strings: [entityItem.primaryText, entityItem.secondaryText],
};
})
.sort((a, b) => caseInsensitiveStringCompare(a.primaryText, b.primaryText));
export const generateCommandItems = (
hass: HomeAssistant
): Array<QuickBarItem[]> => [
generateNavigationCommands(hass),
generateReloadCommands(hass),
generateServerControlCommands(hass),
];
export const generateReloadCommands = (hass: HomeAssistant): QuickBarItem[] => {
// Get all domains that have a direct "reload" service
const reloadableDomains = componentsWithService(hass, "reload");
const commands = reloadableDomains.map((domain) => ({
primaryText:
hass.localize(`ui.dialogs.quick-bar.commands.reload.${domain}`) ||
hass.localize(
"ui.dialogs.quick-bar.commands.reload.reload",
"domain",
domainToName(hass.localize, domain)
),
actionData: [domain, "reload"],
secondaryText: "Reload changes made to the domain file",
}));
// Add "frontend.reload_themes"
commands.push({
primaryText: hass.localize("ui.dialogs.quick-bar.commands.reload.themes"),
actionData: ["frontend", "reload_themes"],
secondaryText: "Reload changes made to themes.yaml",
});
// Add "homeassistant.reload_core_config"
commands.push({
primaryText: hass.localize("ui.dialogs.quick-bar.commands.reload.core"),
actionData: ["homeassistant", "reload_core_config"],
secondaryText: "Reload changes made to configuration.yaml",
});
return commands.map((command) => ({
...command,
categoryKey: "reload",
iconPath: mdiReload,
strings: [
`${hass.localize("ui.dialogs.quick-bar.commands.types.reload")} ${
command.primaryText
}`,
],
}));
};
export const generateServerControlCommands = (
hass: HomeAssistant
): QuickBarItem[] => {
const serverActions = ["restart", "stop"];
return serverActions.map((action) => {
const categoryKey: QuickBarItem["categoryKey"] = "server_control";
const item = {
primaryText: hass.localize(
"ui.dialogs.quick-bar.commands.server_control.perform_action",
"action",
hass.localize(`ui.dialogs.quick-bar.commands.server_control.${action}`)
),
categoryKey,
actionData: action,
};
return {
...item,
strings: [
`${hass.localize(
`ui.dialogs.quick-bar.commands.types.${categoryKey}`
)} ${item.primaryText}`,
],
secondaryText: "Control your server",
iconPath: mdiServerNetwork,
};
});
};
export const generateNavigationCommands = (
hass: HomeAssistant
): QuickBarItem[] => {
const panelItems = generateNavigationPanelCommands(hass);
const sectionItems = generateNavigationConfigSectionCommands(hass);
return finalizeNavigationCommands([...panelItems, ...sectionItems], hass);
};
export const generateNavigationPanelCommands = (
hass: HomeAssistant
): BaseNavigationCommand[] =>
Object.keys(hass.panels)
.filter((panelKey) => panelKey !== "_my_redirect")
.map((panelKey) => {
const panel = hass.panels[panelKey];
const translationKey = getPanelNameTranslationKey(panel);
const primaryText =
hass.localize(translationKey) || panel.title || panel.url_path;
return {
primaryText,
path: `/${panel.url_path}`,
icon: panel.icon,
secondaryText: "Panel",
};
});
export const generateNavigationConfigSectionCommands = (
hass: HomeAssistant
): BaseNavigationCommand[] => {
const items: NavigationInfo[] = [];
for (const sectionKey of Object.keys(configSections)) {
for (const page of configSections[sectionKey]) {
if (!canShowPage(hass, page)) {
continue;
}
if (!page.component) {
continue;
}
const info = getNavigationInfoFromConfig(page, hass);
if (!info) {
continue;
}
// Add to list, but only if we do not already have an entry for the same path and component
if (
items.some(
(e) => e.path === info.path && e.component === info.component
)
) {
continue;
}
items.push({
iconPath: mdiNavigationVariantOutline,
...info,
});
}
}
return items;
};
export const getNavigationInfoFromConfig = (
page: PageNavigation,
hass: HomeAssistant
): NavigationInfo | undefined => {
if (!page.component) {
return undefined;
}
const caption = hass.localize(
`ui.dialogs.quick-bar.commands.navigation.${page.component}`
);
if (page.translationKey && caption) {
return {
...page,
primaryText: caption,
secondaryText: "Configuration Page",
};
}
return undefined;
};
const finalizeNavigationCommands = (
items: BaseNavigationCommand[],
hass: HomeAssistant
): QuickBarItem[] =>
items.map((item) => {
const categoryKey: QuickBarItem["categoryKey"] = "navigation";
const navItem = {
secondaryText: "Navigation",
iconPath: mdiEarth,
...item,
actionData: item.path!,
};
return {
categoryKey,
...navItem,
strings: [
`${hass.localize(
`ui.dialogs.quick-bar.commands.types.${categoryKey}`
)} ${navItem.primaryText}`,
],
};
});

View File

@@ -2,118 +2,114 @@ import "@lit-labs/virtualizer";
import "@material/mwc-list/mwc-list";
import "@material/mwc-list/mwc-list-item";
import type { ListItem } from "@material/mwc-list/mwc-list-item";
import {
mdiClose,
mdiConsoleLine,
mdiEarth,
mdiMagnify,
mdiReload,
mdiServerNetwork,
} from "@mdi/js";
import { css, html, LitElement, TemplateResult } from "lit";
import { mdiClose, mdiMagnify } from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import { css, html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { canShowPage } from "../../common/config/can_show_page";
import { componentsWithService } from "../../common/config/components_with_service";
import { LocalStorage } from "../../common/decorators/local-storage";
import { fireEvent } from "../../common/dom/fire_event";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { domainIcon } from "../../common/entity/domain_icon";
import { navigate } from "../../common/navigate";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import {
fuzzyFilterSort,
ScorableTextItem,
} from "../../common/string/filter/sequence-matching";
import { fuzzyFilterSort } from "../../common/string/filter/sequence-matching";
import { debounce } from "../../common/util/debounce";
import "../../components/ha-chip";
import "../../components/ha-circular-progress";
import "../../components/ha-header-bar";
import "../../components/ha-icon-button";
import "../../components/ha-textfield";
import { domainToName } from "../../data/integration";
import { getPanelNameTranslationKey } from "../../data/panel";
import { PageNavigation } from "../../layouts/hass-tabs-subpage";
import { configSections } from "../../panels/config/ha-panel-config";
import { haStyleDialog, haStyleScrollbar } from "../../resources/styles";
import { HomeAssistant } from "../../types";
import {
ConfirmationDialogParams,
showConfirmationDialog,
} from "../generic/show-dialog-box";
AreaRegistryEntry,
subscribeAreaRegistry,
} from "../../data/area_registry";
import {
DeviceRegistryEntry,
subscribeDeviceRegistry,
} from "../../data/device_registry";
import {
EntityRegistryEntry,
subscribeEntityRegistry,
} from "../../data/entity_registry";
import {
generateCommandItems,
generateEntityItems,
QuickBarItem,
} from "../../data/quick-bar";
import { SubscribeMixin } from "../../mixins/subscribe-mixin";
import {
haStyle,
haStyleDialog,
haStyleScrollbar,
} from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import { QuickBarParams } from "./show-dialog-quick-bar";
interface QuickBarItem extends ScorableTextItem {
primaryText: string;
iconPath?: string;
action(data?: any): void;
}
interface CommandItem extends QuickBarItem {
categoryKey: "reload" | "navigation" | "server_control";
categoryText: string;
}
interface EntityItem extends QuickBarItem {
altText: string;
icon?: string;
}
const isCommandItem = (item: QuickBarItem): item is CommandItem =>
(item as CommandItem).categoryKey !== undefined;
interface QuickBarNavigationItem extends CommandItem {
path: string;
}
type NavigationInfo = PageNavigation & Pick<QuickBarItem, "primaryText">;
type BaseNavigationCommand = Pick<
QuickBarNavigationItem,
"primaryText" | "path"
>;
@customElement("ha-quick-bar")
export class QuickBar extends LitElement {
export class QuickBar extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _commandItems?: CommandItem[];
@state() private _items?: Array<QuickBarItem[]>;
@state() private _entityItems?: EntityItem[];
private _filteredItems?: QuickBarItem[];
@state() private _filter = "";
@state() private _search = "";
@state() private _open = false;
@state() private _commandMode = false;
@state() private _opened = false;
@state() private _done = false;
@state() private _narrow = false;
@state() private _hint?: string;
@state() private _entities?: EntityRegistryEntry[];
@state() private _areas?: AreaRegistryEntry[];
@state() private _devices?: DeviceRegistryEntry[];
@query("ha-textfield", false) private _filterInputField?: HTMLElement;
// @ts-ignore
@LocalStorage("suggestions", true, {
attribute: false,
})
private _suggestions: QuickBarItem[] = [];
private _focusSet = false;
private _focusListElement?: ListItem | null;
private _filterItems = memoizeOne(
(items: QuickBarItem[], filter: string): QuickBarItem[] =>
fuzzyFilterSort<QuickBarItem>(filter.trimLeft(), items)
);
public async showDialog(params: QuickBarParams) {
this._commandMode = params.commandMode || this._toggleIfAlreadyOpened();
this._hint = params.hint;
this._narrow = matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
this._initializeItemsIfNeeded();
this._open = true;
}
public hassSubscribe(): UnsubscribeFunc[] {
return [
subscribeAreaRegistry(this.hass.connection!, (areas) => {
this._areas = areas;
}),
subscribeDeviceRegistry(this.hass.connection!, (devices) => {
this._devices = devices;
}),
subscribeEntityRegistry(this.hass.connection!, (entities) => {
this._entities = entities;
}),
];
}
public closeDialog() {
this._open = false;
this._opened = false;
this._focusSet = false;
this._filter = "";
@@ -121,28 +117,46 @@ export class QuickBar extends LitElement {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _getItems = memoizeOne(
(commandMode: boolean, commandItems, entityItems, filter: string) => {
const items = commandMode ? commandItems : entityItems;
if (items && filter && filter !== " ") {
return this._filterItems(items, filter);
}
return items;
protected willUpdate(changedProperties: PropertyValues): void {
super.willUpdate(changedProperties);
if (
(changedProperties.has("_entity") ||
changedProperties.has("_areas") ||
changedProperties.has("_devices")) &&
this._areas &&
this._devices &&
this._entities
) {
this._initializeItems();
this._opened = true;
}
);
}
protected render() {
if (!this._open) {
protected render(): TemplateResult {
if (!this._opened) {
return html``;
}
const items: QuickBarItem[] | undefined = this._getItems(
this._commandMode,
this._commandItems,
this._entityItems,
this._filter
);
let sectionCount = 0;
if (this._items && this._filter && this._filter !== "") {
const newFilteredItems: QuickBarItem[] = [];
this._items.forEach((arr) => {
const items = this._filterItems(arr, this._filter).slice(0, 3);
if (items.length === 0) {
return;
}
sectionCount++;
newFilteredItems.push(...items);
});
this._filteredItems = newFilteredItems;
} else {
sectionCount++;
this._filteredItems = this._suggestions;
}
return html`
<ha-dialog
@@ -161,28 +175,18 @@ export class QuickBar extends LitElement {
aria-label=${this.hass.localize(
"ui.dialogs.quick-bar.filter_placeholder"
)}
.value=${this._commandMode ? `>${this._search}` : this._search}
icon
.value=${this._search}
.icon=${true}
.iconTrailing=${this._search !== undefined || this._narrow}
@input=${this._handleSearchChange}
@keydown=${this._handleInputKeyDown}
@focus=${this._setFocusFirstListItem}
>
${this._commandMode
? html`
<ha-svg-icon
slot="leadingIcon"
class="prefix"
.path=${mdiConsoleLine}
></ha-svg-icon>
`
: html`
<ha-svg-icon
slot="leadingIcon"
class="prefix"
.path=${mdiMagnify}
></ha-svg-icon>
`}
<ha-svg-icon
slot="leadingIcon"
class="prefix"
.path=${mdiMagnify}
></ha-svg-icon>
${this._search || this._narrow
? html`
<div slot="trailingIcon">
@@ -205,12 +209,11 @@ export class QuickBar extends LitElement {
: ""}
</ha-textfield>
</div>
${!items
? html`<ha-circular-progress
size="small"
active
></ha-circular-progress>`
: items.length === 0
${!this._filteredItems
? html`
<ha-circular-progress size="small" active></ha-circular-progress>
`
: this._filteredItems.length === 0 && this._filter !== ""
? html`
<div class="nothing-found">
${this.hass.localize("ui.dialogs.quick-bar.nothing_found")}
@@ -218,26 +221,26 @@ export class QuickBar extends LitElement {
`
: html`
<mwc-list>
${this._opened
? html`<lit-virtualizer
scroller
@keydown=${this._handleListItemKeyDown}
@rangechange=${this._handleRangeChanged}
@click=${this._handleItemClick}
class="ha-scrollbar"
style=${styleMap({
height: this._narrow
? "calc(100vh - 56px)"
: `${Math.min(
items.length * (this._commandMode ? 56 : 72) + 26,
500
)}px`,
})}
.items=${items}
.renderItem=${this._renderItem}
>
</lit-virtualizer>`
: ""}
<lit-virtualizer
scroller
@keydown=${this._handleListItemKeyDown}
@rangechange=${this._handleRangeChanged}
@click=${this._handleItemClick}
class="ha-scrollbar"
style=${styleMap({
height: this._narrow
? "calc(100vh - 56px)"
: `${Math.min(
this._filteredItems.length * 72 +
sectionCount * 37 +
18,
this._done ? 600 : 0
)}px`,
})}
.items=${this._filteredItems}
.renderItem=${this._renderItem}
>
</lit-virtualizer>
</mwc-list>
`}
${this._hint ? html`<div class="hint">${this._hint}</div>` : ""}
@@ -245,102 +248,111 @@ export class QuickBar extends LitElement {
`;
}
private _initializeItemsIfNeeded() {
if (this._commandMode) {
this._commandItems = this._commandItems || this._generateCommandItems();
} else {
this._entityItems = this._entityItems || this._generateEntityItems();
}
}
private _handleOpened() {
this._opened = true;
}
private async _handleRangeChanged(e) {
if (this._focusSet) {
return;
}
if (e.firstVisible > -1) {
this._focusSet = true;
await this.updateComplete;
this._setFocusFirstListItem();
}
}
private _renderItem = (item: QuickBarItem, index: number): TemplateResult => {
if (!item) {
return html``;
}
return isCommandItem(item)
? this._renderCommandItem(item, index)
: this._renderEntityItem(item as EntityItem, index);
const previous = this._filteredItems![index - 1];
return html`
<div class="entry-container" style="z-index: 5">
${index === 0 || item?.categoryKey !== previous?.categoryKey
? html`
<div class="entry-title">
${item.isSuggestion
? this.hass.localize(
"ui.dialogs.quick-bar.commands.types.suggestions"
)
: this.hass.localize(
`ui.dialogs.quick-bar.commands.types.${item.categoryKey}`
)}
</div>
`
: ""}
<mwc-list-item
.twoline=${Boolean(item.secondaryText)}
.hasMeta=${Boolean(item.metaText)}
.item=${item}
index=${ifDefined(index)}
graphic="icon"
class=${item.secondaryText ? "single-line" : ""}
>
${item.iconPath
? html`<ha-svg-icon
.path=${item.iconPath}
slot="graphic"
></ha-svg-icon>`
: html`<ha-icon .icon=${item.icon} slot="graphic"></ha-icon>`}
<span
>${item.primaryText}
<span class="secondary">${item.primaryTextAlt}</span></span
>
${item.secondaryText
? html`
<span slot="secondary" class="item-text secondary"
>${item.secondaryText}</span
>
`
: ""}
${item.metaText
? html`<ha-chip slot="meta">${item.metaText}</ha-chip>`
: ""}
</mwc-list-item>
</div>
`;
};
private _renderEntityItem(item: EntityItem, index?: number) {
return html`
<mwc-list-item
.twoline=${Boolean(item.altText)}
.item=${item}
index=${ifDefined(index)}
graphic="icon"
>
${item.iconPath
? html`<ha-svg-icon
.path=${item.iconPath}
class="entity"
slot="graphic"
></ha-svg-icon>`
: html`<ha-icon
.icon=${item.icon}
class="entity"
slot="graphic"
></ha-icon>`}
<span>${item.primaryText}</span>
${item.altText
? html`
<span slot="secondary" class="item-text secondary"
>${item.altText}</span
>
`
: null}
</mwc-list-item>
`;
}
private _initializeItems() {
const deviceLookup: { [deviceId: string]: DeviceRegistryEntry } = {};
for (const device of this._devices!) {
deviceLookup[device.id] = device;
}
private _renderCommandItem(item: CommandItem, index?: number) {
return html`
<mwc-list-item
.item=${item}
index=${ifDefined(index)}
class="command-item"
hasMeta
>
<span>
<ha-chip
.label=${item.categoryText}
hasIcon
class="command-category ${item.categoryKey}"
>
${item.iconPath
? html`<ha-svg-icon
.path=${item.iconPath}
slot="icon"
></ha-svg-icon>`
: ""}
${item.categoryText}</ha-chip
>
</span>
const entityLookup: { [entityId: string]: EntityRegistryEntry } = {};
for (const entity of this._entities!) {
entityLookup[entity.entity_id] = entity;
}
<span class="command-text">${item.primaryText}</span>
</mwc-list-item>
`;
const areaLookup: { [areaId: string]: AreaRegistryEntry } = {};
for (const area of this._areas!) {
areaLookup[area.area_id] = area;
}
this._items = this._items || [
generateEntityItems(this.hass, entityLookup, deviceLookup, areaLookup),
...generateCommandItems(this.hass),
];
}
private async processItemAndCloseDialog(item: QuickBarItem, index: number) {
this._addSpinnerToCommandItem(index);
if (!this._suggestions.includes(item)) {
this._suggestions.unshift({ ...item, isSuggestion: true });
this._suggestions = this._suggestions.slice(0, 3);
}
await item.action();
this._addSpinnerToItem(index);
switch (item.categoryKey) {
case "entity":
fireEvent(this, "hass-more-info", {
entityId: item.actionData as string,
});
break;
case "reload":
this.hass.callService(item.actionData[0], item.actionData[1]);
break;
case "navigation":
navigate(item.actionData as string);
break;
case "server_control":
showConfirmationDialog(this, {
confirmText: this.hass.localize("ui.dialogs.generic.ok"),
confirm: () =>
this.hass.callService("homeassistant", item.actionData as string),
});
break;
}
this.closeDialog();
}
@@ -359,56 +371,28 @@ export class QuickBar extends LitElement {
}
}
private _getItemAtIndex(index: number): ListItem | null {
return this.renderRoot.querySelector(`mwc-list-item[index="${index}"]`);
}
private _addSpinnerToCommandItem(index: number): void {
const spinner = document.createElement("ha-circular-progress");
spinner.size = "small";
spinner.slot = "meta";
spinner.active = true;
this._getItemAtIndex(index)?.appendChild(spinner);
}
private _handleSearchChange(ev: CustomEvent): void {
const newFilter = (ev.currentTarget as any).value;
const oldCommandMode = this._commandMode;
const oldSearch = this._search;
let newCommandMode: boolean;
let newSearch: string;
if (newFilter.startsWith(">")) {
newCommandMode = true;
newSearch = newFilter.substring(1);
} else {
newCommandMode = false;
newSearch = newFilter;
}
if (oldCommandMode === newCommandMode && oldSearch === newSearch) {
return;
}
this._commandMode = newCommandMode;
this._search = newSearch;
if (this._hint) {
this._hint = undefined;
}
if (oldCommandMode !== this._commandMode) {
if (this._focusSet && this._focusListElement) {
this._focusSet = false;
this._initializeItemsIfNeeded();
this._filter = this._search;
} else {
if (this._focusSet && this._focusListElement) {
this._focusSet = false;
// @ts-ignore
this._focusListElement.rippleHandlers.endFocus();
}
this._debouncedSetFilter(this._search);
// @ts-ignore
this._focusListElement.rippleHandlers.endFocus();
}
this._debouncedSetFilter(this._search);
}
private _clearSearch() {
@@ -454,258 +438,62 @@ export class QuickBar extends LitElement {
}
private _handleItemClick(ev) {
const listItem = ev.target.closest("mwc-list-item");
const target =
ev.target.nodeName === "MWC-LIST-ITEM"
? ev.target
: ev.target.parentElement === "MWC-LIST-ITEM"
? ev.target.parentElement
: ev.target.parentElement.parentElement;
// Need to resolve when clicking on the chip
if (!target.item) {
return;
}
this.processItemAndCloseDialog(
listItem.item,
Number(listItem.getAttribute("index"))
target.item,
Number((target as HTMLElement).getAttribute("index"))
);
}
private _generateEntityItems(): EntityItem[] {
return Object.keys(this.hass.states)
.map((entityId) => {
const entityState = this.hass.states[entityId];
const entityItem = {
primaryText: computeStateName(entityState),
altText: entityId,
icon: entityState.attributes.icon,
iconPath: entityState.attributes.icon
? undefined
: domainIcon(computeDomain(entityId), entityState),
action: () => fireEvent(this, "hass-more-info", { entityId }),
};
return {
...entityItem,
strings: [entityItem.primaryText, entityItem.altText],
};
})
.sort((a, b) =>
caseInsensitiveStringCompare(a.primaryText, b.primaryText)
);
}
private _generateCommandItems(): CommandItem[] {
return [
...this._generateReloadCommands(),
...this._generateServerControlCommands(),
...this._generateNavigationCommands(),
].sort((a, b) =>
caseInsensitiveStringCompare(a.strings.join(" "), b.strings.join(" "))
);
}
private _generateReloadCommands(): CommandItem[] {
// Get all domains that have a direct "reload" service
const reloadableDomains = componentsWithService(this.hass, "reload");
const commands = reloadableDomains.map((domain) => ({
primaryText:
this.hass.localize(`ui.dialogs.quick-bar.commands.reload.${domain}`) ||
this.hass.localize(
"ui.dialogs.quick-bar.commands.reload.reload",
"domain",
domainToName(this.hass.localize, domain)
),
action: () => this.hass.callService(domain, "reload"),
iconPath: mdiReload,
categoryText: this.hass.localize(
`ui.dialogs.quick-bar.commands.types.reload`
),
}));
// Add "frontend.reload_themes"
commands.push({
primaryText: this.hass.localize(
"ui.dialogs.quick-bar.commands.reload.themes"
),
action: () => this.hass.callService("frontend", "reload_themes"),
iconPath: mdiReload,
categoryText: this.hass.localize(
"ui.dialogs.quick-bar.commands.types.reload"
),
});
// Add "homeassistant.reload_core_config"
commands.push({
primaryText: this.hass.localize(
"ui.dialogs.quick-bar.commands.reload.core"
),
action: () =>
this.hass.callService("homeassistant", "reload_core_config"),
iconPath: mdiReload,
categoryText: this.hass.localize(
"ui.dialogs.quick-bar.commands.types.reload"
),
});
return commands.map((command) => ({
...command,
categoryKey: "reload",
strings: [`${command.categoryText} ${command.primaryText}`],
}));
}
private _generateServerControlCommands(): CommandItem[] {
const serverActions = ["restart", "stop"];
return serverActions.map((action) => {
const categoryKey: CommandItem["categoryKey"] = "server_control";
const item = {
primaryText: this.hass.localize(
"ui.dialogs.quick-bar.commands.server_control.perform_action",
"action",
this.hass.localize(
`ui.dialogs.quick-bar.commands.server_control.${action}`
)
),
iconPath: mdiServerNetwork,
categoryText: this.hass.localize(
`ui.dialogs.quick-bar.commands.types.${categoryKey}`
),
categoryKey,
action: () => this.hass.callService("homeassistant", action),
};
return this._generateConfirmationCommand(
{
...item,
strings: [`${item.categoryText} ${item.primaryText}`],
},
this.hass.localize("ui.dialogs.generic.ok")
);
private _handleOpened() {
this.updateComplete.then(() => {
this._done = true;
});
}
private _generateNavigationCommands(): CommandItem[] {
const panelItems = this._generateNavigationPanelCommands();
const sectionItems = this._generateNavigationConfigSectionCommands();
return this._finalizeNavigationCommands([...panelItems, ...sectionItems]);
}
private _generateNavigationPanelCommands(): BaseNavigationCommand[] {
return Object.keys(this.hass.panels)
.filter((panelKey) => panelKey !== "_my_redirect")
.map((panelKey) => {
const panel = this.hass.panels[panelKey];
const translationKey = getPanelNameTranslationKey(panel);
const primaryText =
this.hass.localize(translationKey) || panel.title || panel.url_path;
return {
primaryText,
path: `/${panel.url_path}`,
};
});
}
private _generateNavigationConfigSectionCommands(): BaseNavigationCommand[] {
const items: NavigationInfo[] = [];
for (const sectionKey of Object.keys(configSections)) {
for (const page of configSections[sectionKey]) {
if (!canShowPage(this.hass, page)) {
continue;
}
if (!page.component) {
continue;
}
const info = this._getNavigationInfoFromConfig(page);
if (!info) {
continue;
}
// Add to list, but only if we do not already have an entry for the same path and component
if (
items.some(
(e) => e.path === info.path && e.component === info.component
)
) {
continue;
}
items.push(info);
}
private async _handleRangeChanged(e) {
if (this._focusSet) {
return;
}
return items;
}
private _getNavigationInfoFromConfig(
page: PageNavigation
): NavigationInfo | undefined {
if (!page.component) {
return undefined;
if (e.firstVisible > -1) {
this._focusSet = true;
await this.updateComplete;
this._setFocusFirstListItem();
}
const caption = this.hass.localize(
`ui.dialogs.quick-bar.commands.navigation.${page.component}`
);
if (page.translationKey && caption) {
return { ...page, primaryText: caption };
}
return undefined;
}
private _generateConfirmationCommand(
item: CommandItem,
confirmText: ConfirmationDialogParams["confirmText"]
): CommandItem {
return {
...item,
action: () =>
showConfirmationDialog(this, {
confirmText,
confirm: item.action,
}),
};
private _getItemAtIndex(index: number): ListItem | null {
return this.renderRoot.querySelector(`mwc-list-item[index="${index}"]`);
}
private _finalizeNavigationCommands(
items: BaseNavigationCommand[]
): CommandItem[] {
return items.map((item) => {
const categoryKey: CommandItem["categoryKey"] = "navigation";
const navItem = {
...item,
iconPath: mdiEarth,
categoryText: this.hass.localize(
`ui.dialogs.quick-bar.commands.types.${categoryKey}`
),
action: () => navigate(item.path),
};
return {
...navItem,
strings: [`${navItem.categoryText} ${navItem.primaryText}`],
categoryKey,
};
});
private _addSpinnerToItem(index: number): void {
const spinner = document.createElement("ha-circular-progress");
spinner.size = "small";
spinner.slot = "meta";
spinner.active = true;
this._getItemAtIndex(index)?.appendChild(spinner);
}
private _toggleIfAlreadyOpened() {
return this._opened ? !this._commandMode : false;
}
private _filterItems = memoizeOne(
(items: QuickBarItem[], filter: string): QuickBarItem[] =>
fuzzyFilterSort<QuickBarItem>(filter.trimLeft(), items)
);
static get styles() {
return [
haStyleScrollbar,
haStyleDialog,
haStyle,
css`
.heading {
display: flex;
align-items: center;
--mdc-theme-primary: var(--primary-text-color);
}
.heading ha-textfield {
@@ -739,8 +527,8 @@ export class QuickBar extends LitElement {
}
}
ha-icon.entity,
ha-svg-icon.entity {
mwc-list-item ha-icon,
mwc-list-item ha-svg-icon {
margin-left: 20px;
}
@@ -748,38 +536,30 @@ export class QuickBar extends LitElement {
color: var(--primary-text-color);
}
ha-textfield {
--mdc-text-field-fill-color: transparent;
--mdc-theme-primary: var(--divider-color);
--mdc-text-field-idle-line-color: var(--divider-color);
}
ha-textfield ha-icon-button {
--mdc-icon-button-size: 24px;
color: var(--primary-text-color);
}
.command-category {
--ha-chip-icon-color: #585858;
--ha-chip-text-color: #212121;
.entry-container {
width: 100%;
}
.command-category.reload {
--ha-chip-background-color: #cddc39;
}
.command-category.navigation {
--ha-chip-background-color: var(--light-primary-color);
}
.command-category.server_control {
--ha-chip-background-color: var(--warning-color);
}
span.command-text {
margin-left: 8px;
.entry-title {
padding-left: 16px;
padding-top: 16px;
color: var(--secondary-text-color);
}
mwc-list-item {
width: 100%;
}
mwc-list-item.command-item {
text-transform: capitalize;
box-sizing: border-box;
}
.hint {
@@ -801,6 +581,10 @@ export class QuickBar extends LitElement {
lit-virtualizer {
contain: size layout !important;
}
ha-chip {
margin-left: -48px;
}
`,
];
}

View File

@@ -312,6 +312,7 @@ export const haStyleDialog = css`
--mdc-dialog-heading-ink-color: var(--primary-text-color);
--mdc-dialog-content-ink-color: var(--primary-text-color);
--justify-action-buttons: space-between;
--ha-dialog-border-radius: 16px;
}
ha-dialog .form {

View File

@@ -664,8 +664,10 @@
},
"types": {
"reload": "Reload",
"navigation": "Navigate",
"server_control": "Server"
"navigation": "Navigation",
"server_control": "Server",
"entity": "Entity",
"suggestion": "Suggestions"
},
"navigation": {
"logs": "[%key:ui::panel::config::logs::caption%]",
@@ -689,7 +691,7 @@
"server_control": "[%key:ui::panel::config::server_control::caption%]"
}
},
"filter_placeholder": "Entity Filter",
"filter_placeholder": "Search for entities, pages or commands",
"title": "Quick Search",
"key_c_hint": "Press 'c' on any page to open this search bar",
"nothing_found": "Nothing found!"