diff --git a/src/common/util/subscribe-one.ts b/src/common/util/subscribe-one.ts index afef348d9f..4429544807 100644 --- a/src/common/util/subscribe-one.ts +++ b/src/common/util/subscribe-one.ts @@ -1,4 +1,8 @@ -import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket"; +import type { + Collection, + Connection, + UnsubscribeFunc, +} from "home-assistant-js-websocket"; export const subscribeOne = async ( conn: Connection, @@ -13,3 +17,11 @@ export const subscribeOne = async ( resolve(items); }); }); + +export const subscribeOneCollection = async (collection: Collection) => + new Promise((resolve) => { + const unsub = collection.subscribe((data) => { + unsub(); + resolve(data); + }); + }); diff --git a/src/components/ha-navigation-picker.ts b/src/components/ha-navigation-picker.ts index 3533856adb..c47aaf4572 100644 --- a/src/components/ha-navigation-picker.ts +++ b/src/components/ha-navigation-picker.ts @@ -2,9 +2,12 @@ import Fuse from "fuse.js"; import { html, LitElement, nothing, type PropertyValues } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoizeOne from "memoize-one"; +import { isComponentLoaded } from "../common/config/is_component_loaded"; import { fireEvent } from "../common/dom/fire_event"; +import { subscribeOneCollection } from "../common/util/subscribe-one"; import { caseInsensitiveStringCompare } from "../common/string/compare"; import { getConfigEntries, type ConfigEntry } from "../data/config_entries"; +import { getIngressPanelInfoCollection } from "../data/hassio/ingress"; import { fetchConfig } from "../data/lovelace/config/types"; import { SYSTEM_PANELS } from "../data/panel"; import { @@ -25,7 +28,12 @@ import { type PickerComboBoxItem, } from "./ha-picker-combo-box"; -type NavigationGroup = "related" | "dashboards" | "views" | "other_routes"; +type NavigationGroup = + | "related" + | "dashboards" + | "views" + | "apps" + | "other_routes"; const RELATED_SORT_PREFIX = { area_view: "0_area_view", @@ -78,6 +86,7 @@ export class HaNavigationPicker extends LitElement { related: [], dashboards: [], views: [], + apps: [], other_routes: [], }; @@ -107,6 +116,14 @@ export class HaNavigationPicker extends LitElement { id: "views", label: this.hass.localize("ui.components.navigation-picker.views"), }, + ...(this._navigationGroups.apps.length + ? [ + { + id: "apps", + label: this.hass.localize("ui.components.navigation-picker.apps"), + }, + ] + : []), { id: "other_routes", label: this.hass.localize( @@ -199,6 +216,9 @@ export class HaNavigationPicker extends LitElement { views: memoizeOne((items: NavigationItem[]) => Fuse.createIndex(DEFAULT_SEARCH_KEYS, items) ), + apps: memoizeOne((items: NavigationItem[]) => + Fuse.createIndex(DEFAULT_SEARCH_KEYS, items) + ), other_routes: memoizeOne((items: NavigationItem[]) => Fuse.createIndex(DEFAULT_SEARCH_KEYS, items) ), @@ -237,6 +257,7 @@ export class HaNavigationPicker extends LitElement { const related = getGroupItems("related"); const dashboards = getGroupItems("dashboards"); const views = getGroupItems("views"); + const apps = getGroupItems("apps"); const otherRoutes = getGroupItems("other_routes"); const addGroup = (group: NavigationGroup, groupItems: NavigationItem[]) => { @@ -254,6 +275,7 @@ export class HaNavigationPicker extends LitElement { addGroup("related", related); addGroup("dashboards", dashboards); addGroup("views", views); + addGroup("apps", apps); addGroup("other_routes", otherRoutes); return items; @@ -292,10 +314,13 @@ export class HaNavigationPicker extends LitElement { const related = this._navigationGroups.related; const dashboards: NavigationItem[] = []; const views: NavigationItem[] = []; + const apps: NavigationItem[] = []; const otherRoutes: NavigationItem[] = []; for (const panel of panels) { if (SYSTEM_PANELS.includes(panel.id)) continue; + // Skip app panels — they are handled by the ingress panels fetch below + if (panel.component_name === "app") continue; const path = `/${panel.url_path}`; const resolved = computeNavigationPathInfo(this.hass!, path); const isDashboardPanel = @@ -338,6 +363,35 @@ export class HaNavigationPicker extends LitElement { }); } + // Fetch all ingress add-on panels + if (isComponentLoaded(this.hass!.config, "hassio")) { + try { + const ingressPanels = await subscribeOneCollection( + getIngressPanelInfoCollection(this.hass!.connection) + ); + for (const slug of Object.keys(ingressPanels)) { + const path = `/app/${slug}`; + const resolved = computeNavigationPathInfo( + this.hass!, + path, + undefined, + ingressPanels + ); + apps.push({ + id: path, + primary: resolved.label, + secondary: path, + icon: resolved.icon, + icon_path: resolved.iconPath, + sorting_label: createSortingLabel(resolved.label, path), + group: "apps", + }); + } + } catch (_err) { + // Supervisor may not be available, silently ignore + } + } + for (const [subPath, route] of Object.entries(CONFIG_SUB_ROUTES)) { const path = `/config/${subPath}`; const label = this.hass!.localize(route.translationKey) || subPath; @@ -355,6 +409,7 @@ export class HaNavigationPicker extends LitElement { related, dashboards, views, + apps, other_routes: otherRoutes, }; @@ -362,6 +417,7 @@ export class HaNavigationPicker extends LitElement { ...related, ...dashboards, ...views, + ...apps, ...otherRoutes, ]; @@ -384,6 +440,7 @@ export class HaNavigationPicker extends LitElement { ...relatedItems, ...this._navigationGroups.dashboards, ...this._navigationGroups.views, + ...this._navigationGroups.apps, ...this._navigationGroups.other_routes, ]; }; diff --git a/src/data/compute-navigation-path-info.ts b/src/data/compute-navigation-path-info.ts index b75d8dfb60..c490d49c3d 100644 --- a/src/data/compute-navigation-path-info.ts +++ b/src/data/compute-navigation-path-info.ts @@ -12,7 +12,10 @@ import { import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import { isComponentLoaded } from "../common/config/is_component_loaded"; import { computeDeviceName } from "../common/entity/compute_device_name"; -import { getIngressPanelInfoCollection } from "./hassio/ingress"; +import { + getIngressPanelInfoCollection, + type IngressPanelInfoMap, +} from "./hassio/ingress"; import { getLovelaceCollection } from "./lovelace"; import type { LovelaceRawConfig } from "./lovelace/config/types"; import { computeViewIcon, computeViewTitle } from "./lovelace/config/view"; @@ -80,7 +83,8 @@ export const CONFIG_SUB_ROUTES: Record< export const computeNavigationPathInfo = ( hass: HomeAssistant, path: string, - lovelaceConfig?: LovelaceRawConfig + lovelaceConfig?: LovelaceRawConfig, + ingressPanels?: IngressPanelInfoMap ): NavigationPathInfo => { const segments = path.replace(/^\//, "").split(/[/?]/); const panelUrlPath = segments[0]; @@ -106,6 +110,11 @@ export const computeNavigationPathInfo = ( return computeDeviceNavigationPathInfo(hass, segments[3]); } + // /app/ (ingress addon panel) + if (panelUrlPath === APP_PANEL && subPath) { + return computeIngressNavigationPathInfo(subPath, ingressPanels); + } + // /config/{subRoute} (e.g. /config/automation, /config/integrations) if (panelUrlPath === "config" && subPath && subPath in CONFIG_SUB_ROUTES) { const route = CONFIG_SUB_ROUTES[subPath]; @@ -174,23 +183,69 @@ const computeDeviceNavigationPathInfo = ( }; }; +const computeIngressNavigationPathInfo = ( + slug: string, + ingressPanels?: IngressPanelInfoMap +): NavigationPathInfo => { + const panel = ingressPanels?.[slug]; + return { + label: panel?.title || slug, + icon: panel?.icon || undefined, + iconPath: mdiPuzzle, + }; +}; + /** * Subscribe to navigation path info updates. * Resolves synchronously first, then subscribes to lovelace config - * updates for view paths. + * updates for view paths and ingress panel info for app paths. */ export const subscribeNavigationPathInfo = ( hass: HomeAssistant, path: string, onChange: (info: NavigationPathInfo) => void ): UnsubscribeFunc | undefined => { + const segments = path.replace(/^\//, "").split(/[/?]/); + const panelUrlPath = segments[0]; + + // Subscribe to ingress panels for /app/ paths + if ( + panelUrlPath === APP_PANEL && + segments[1] && + isComponentLoaded(hass.config, "hassio") + ) { + try { + const collection = getIngressPanelInfoCollection(hass.connection); + // Use cached state for immediate resolution if available + const info = computeNavigationPathInfo( + hass, + path, + undefined, + collection.state + ); + onChange(info); + let current = info; + return collection.subscribe((panels) => { + const newInfo = computeNavigationPathInfo( + hass, + path, + undefined, + panels + ); + if (newInfo.label !== current.label || newInfo.icon !== current.icon) { + current = newInfo; + onChange(newInfo); + } + }); + } catch (_err) { + // Supervisor may not be available + } + } + const info = computeNavigationPathInfo(hass, path); onChange(info); - const segments = path.replace(/^\//, "").split(/[/?]/); - const panelUrlPath = segments[0]; const panel = panelUrlPath ? hass.panels[panelUrlPath] : undefined; - if (segments[1] && panel?.component_name === "lovelace") { let current = info; const collection = getLovelaceCollection(hass.connection, panelUrlPath); @@ -203,25 +258,5 @@ export const subscribeNavigationPathInfo = ( }); } - // /app/{addonSlug} - if ( - panelUrlPath === APP_PANEL && - segments[1] && - isComponentLoaded(hass.config, "hassio") - ) { - const addonSlug = segments[1]; - const collection = getIngressPanelInfoCollection(hass.connection); - return collection.subscribe((addonMap) => { - const addon = addonMap[addonSlug]; - if (addon) { - onChange({ - label: addon.title, - icon: addon.icon, - iconPath: info.iconPath, - }); - } - }); - } - return undefined; }; diff --git a/src/data/hassio/ingress.ts b/src/data/hassio/ingress.ts index 7d4b3ca36f..fa6cb96bc7 100644 --- a/src/data/hassio/ingress.ts +++ b/src/data/hassio/ingress.ts @@ -35,7 +35,7 @@ export interface IngressPanelInfo { icon: string; } -type IngressPanelInfoMap = Record; +export type IngressPanelInfoMap = Record; export const getIngressPanelInfoCollection = (conn: Connection) => getCollection( diff --git a/src/translations/en.json b/src/translations/en.json index 90fe89714e..f2354d8c0c 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1435,6 +1435,7 @@ "related": "Related", "views": "Views", "area_settings": "{area} - Settings", + "apps": "[%key:panel::apps%]", "other_routes": "Other routes", "route": { "automations": "[%key:ui::panel::config::automation::caption%]", diff --git a/test/data/compute-navigation-path-info.test.ts b/test/data/compute-navigation-path-info.test.ts index da8712e767..da6b25ff0e 100644 --- a/test/data/compute-navigation-path-info.test.ts +++ b/test/data/compute-navigation-path-info.test.ts @@ -1,6 +1,7 @@ -import { mdiDevices, mdiLink, mdiTextureBox } from "@mdi/js"; +import { mdiDevices, mdiLink, mdiPuzzle, mdiTextureBox } from "@mdi/js"; import { describe, expect, it } from "vitest"; import { computeNavigationPathInfo } from "../../src/data/compute-navigation-path-info"; +import type { IngressPanelInfoMap } from "../../src/data/hassio/ingress"; import type { HomeAssistant } from "../../src/types"; import type { LovelaceConfig } from "../../src/data/lovelace/config/types"; @@ -262,4 +263,84 @@ describe("computeNavigationPathInfo", () => { expect(result.label).toBe("Dashboard"); }); }); + + describe("ingress panel paths", () => { + const ingressPanels: IngressPanelInfoMap = { + my_addon: { + title: "My Addon", + icon: "mdi:puzzle", + }, + no_icon_addon: { + title: "No Icon Addon", + icon: "", + }, + }; + + it("resolves /app/ with ingress panels data", () => { + const hass = createHass(); + const result = computeNavigationPathInfo( + hass, + "/app/my_addon", + undefined, + ingressPanels + ); + expect(result.label).toBe("My Addon"); + expect(result.icon).toBe("mdi:puzzle"); + expect(result.iconPath).toBe(mdiPuzzle); + }); + + it("falls back to slug when ingress panels not provided", () => { + const hass = createHass(); + const result = computeNavigationPathInfo(hass, "/app/my_addon"); + expect(result.label).toBe("my_addon"); + expect(result.icon).toBeUndefined(); + expect(result.iconPath).toBe(mdiPuzzle); + }); + + it("falls back to slug when addon not found in ingress panels", () => { + const hass = createHass(); + const result = computeNavigationPathInfo( + hass, + "/app/unknown_addon", + undefined, + ingressPanels + ); + expect(result.label).toBe("unknown_addon"); + expect(result.icon).toBeUndefined(); + expect(result.iconPath).toBe(mdiPuzzle); + }); + + it("resolves addon with empty icon as undefined", () => { + const hass = createHass(); + const result = computeNavigationPathInfo( + hass, + "/app/no_icon_addon", + undefined, + ingressPanels + ); + expect(result.label).toBe("No Icon Addon"); + expect(result.icon).toBeUndefined(); + expect(result.iconPath).toBe(mdiPuzzle); + }); + + it("does not resolve /app without a slug", () => { + const hass = createHass({ + panels: { + app: { + url_path: "app", + title: "Apps", + component_name: "app", + }, + } as unknown as HomeAssistant["panels"], + }); + const result = computeNavigationPathInfo( + hass, + "/app", + undefined, + ingressPanels + ); + // Falls through to panel resolution, not ingress + expect(result.label).toBe("Apps"); + }); + }); });