Compare commits

..

9 Commits

Author SHA1 Message Date
Paul Bottein 2599f97aae Unify back navigation across subpages 2026-08-04 19:06:55 +02:00
Petar Petrov 1b6e150def Remove dead UV_THREADPOOL_SIZE assignment from gulpfile (#53490)
gulpfile.js set process.env.UV_THREADPOOL_SIZE, but gulp's CLI loads the
gulpfile through liftoff, which does async fs work first. libuv has already
sized its threadpool to the default 4 by then, so the assignment never took
effect. Measured through the gulp CLI, effective threadpool size is 3.4 both
with and without the line, and only reaches 6.9 when the variable comes from
the environment.

Hoisting it earlier cannot fix it either, since the pool is sized before any
line of gulpfile.js runs, and setting it for real measurably changes nothing.
Remove it rather than plumb the variable through the build.
2026-08-04 15:52:42 +02:00
Petar Petrov 939b7012e2 Fix wrong month in date picker calendar header (#53453) 2026-08-04 15:37:00 +02:00
Paulus Schoutsen 488d3d9b37 Revert "Show the automation state on mobile in the automations table" (#53493) 2026-08-04 15:25:41 +02:00
Petar Petrov 08dedc415f Compress faster by running zopfli in a worker pool (#53488)
Compress with zopfli in a worker pool

compress-app gzips every build artifact with zopfli, but @gfx/zopfli is
synchronous WASM: it ran on the main thread, pinned a single core and blocked
the event loop for the whole step, so it dominated the production build.

Replace gulp-zopfli-green with an equivalent gulp transform that runs the same
compressor in a pool of worker_threads sized to availableParallelism(). The
compressed output is byte-identical, and @gfx/zopfli is no longer loaded on the
main thread of every gulp invocation.

On a 12-core machine compress-app drops from 6.98 min to 1.27 min, and the full
production build from 8.87 min to 3.32 min.
2026-08-04 15:11:45 +02:00
karwosts 400bf78ce0 Color icons for "on" automations in automation-picker (#53360)
colorize active icons in automation-picker
2026-08-04 15:46:14 +03:00
Przemysław Szypowicz 9b2093125e Fix unreachable remove button on input chips with long labels (#53487)
Truncate long input chip labels

A chip with a long label, like an SSH public key in an add-on config
list option, grew past its container so the trailing remove button
ended up outside the visible area and could not be clicked. Cap the
chip at the container width so the built-in label ellipsis applies,
and show the full value as a native tooltip in the select selector.

Co-authored-by: Przemysław Szypowicz <2733699+pszypowicz@users.noreply.github.com>
2026-08-04 15:42:35 +03:00
renovate[bot] c0d963747a Update dependency @types/luxon to v3.7.3 (#53484)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-04 13:14:02 +03:00
Paul Bottein 1bcd43fb75 Add form divider between actions in interaction editor (#53483) 2026-08-04 11:55:07 +02:00
78 changed files with 982 additions and 780 deletions
+1 -1
View File
@@ -3,8 +3,8 @@
import { constants } from "node:zlib";
import gulp from "gulp";
import brotli from "gulp-brotli";
import zopfli from "gulp-zopfli-green";
import paths from "../paths.cjs";
import zopfli from "../zopfli.mjs";
const filesGlob = "*.{js,json,css,svg,xml}";
const brotliOptions = {
+18
View File
@@ -0,0 +1,18 @@
// Worker side of the zopfli pool. @gfx/zopfli is a synchronous WASM build, so
// compressing on the main thread blocks the event loop; one instance per worker
// is what makes the work parallel.
import { parentPort } from "node:worker_threads";
import zopfli from "@gfx/zopfli";
parentPort.on("message", ({ contents, options }) => {
zopfli.gzip(contents, options, (error, result) => {
if (error) {
parentPort.postMessage({ error: error.message ?? String(error) });
return;
}
// `result` is a fresh Uint8Array copied out of the WASM heap, so it owns
// its ArrayBuffer and can be transferred instead of cloned.
parentPort.postMessage({ result }, [result.buffer]);
});
});
+196
View File
@@ -0,0 +1,196 @@
// Gulp transform that gzips files with zopfli across a pool of worker threads.
//
// Drop-in replacement for gulp-zopfli-green. That plugin compresses on the main
// thread, and @gfx/zopfli is synchronous WASM, so it pins a single core and
// blocks the event loop for the whole compression step. Running one WASM
// instance per worker parallelises it; the output bytes are unchanged.
import { availableParallelism } from "node:os";
import { Transform } from "node:stream";
import { buffer as readStream } from "node:stream/consumers";
import { Worker } from "node:worker_threads";
const WORKER_URL = new URL("./zopfli-worker.mjs", import.meta.url);
const EXTENSION = ".gz";
// Left empty on purpose: @gfx/zopfli then applies its own defaults, which is
// what gulp-zopfli-green did, so compressed output stays byte-identical.
const ZOPFLI_OPTIONS = {};
const poolSize = () => {
const configured = Number(process.env.ZOPFLI_WORKERS);
return Number.isInteger(configured) && configured > 0
? configured
: availableParallelism();
};
// One job per worker at a time, so the worker itself is the job slot.
const createPool = (size) => {
const live = new Set();
const idle = [];
const waiting = [];
const inFlight = new Map();
// A dead worker must leave the pool, or it gets handed a job that never
// completes and the build hangs instead of failing.
const retire = (worker, error) => {
if (!live.delete(worker)) {
return;
}
const index = idle.indexOf(worker);
if (index !== -1) {
idle.splice(index, 1);
}
const job = inFlight.get(worker);
inFlight.delete(worker);
job?.reject(error);
waiting.shift()?.();
};
const spawn = () => {
const worker = new Worker(WORKER_URL);
live.add(worker);
// Idle workers must not hold the process open. Each job refs its worker for
// as long as it runs, so a pending compression always keeps the event loop
// alive.
worker.unref();
worker.on("message", ({ error, result }) => {
const job = inFlight.get(worker);
if (!job) {
return;
}
inFlight.delete(worker);
worker.unref();
idle.push(worker);
if (error) {
job.reject(new Error(error));
} else {
job.resolve(
Buffer.from(result.buffer, result.byteOffset, result.byteLength)
);
}
waiting.shift()?.();
});
worker.on("error", (error) => retire(worker, error));
worker.on("exit", () =>
retire(worker, new Error("zopfli worker exited unexpectedly"))
);
return worker;
};
return (contents) =>
new Promise((resolve, reject) => {
const start = () => {
const worker = idle.pop() ?? (live.size < size ? spawn() : undefined);
if (!worker) {
waiting.push(start);
return;
}
inFlight.set(worker, { resolve, reject });
worker.ref();
// `contents` is cloned rather than transferred: buffers read by vinyl
// can share a pooled ArrayBuffer with unrelated buffers, and
// transferring would detach those too.
worker.postMessage({ contents, options: ZOPFLI_OPTIONS });
};
start();
});
};
// Shared by every transform this module hands out, so the worker count is a
// property of the process rather than of how many streams happen to run.
let pool;
const sharedPool = () => {
if (!pool) {
const size = poolSize();
pool = { size, compress: createPool(size) };
}
return pool;
};
// through2 and node's Transform both wait for the previous callback before
// handling the next file, which would serialise the pool down to one worker.
// This keeps `limit` files in flight and applies backpressure beyond that.
class ParallelTransform extends Transform {
#limit;
#handle;
#inFlight = 0;
#resume;
#finish;
constructor(limit, handle) {
super({ objectMode: true, highWaterMark: limit });
this.#limit = limit;
this.#handle = handle;
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_transform(file, _encoding, callback) {
this.#inFlight += 1;
this.#handle(file)
.then((result) => {
if (result) {
this.push(result);
}
})
.catch((error) => this.destroy(error))
.finally(() => {
this.#inFlight -= 1;
const resume = this.#resume;
this.#resume = undefined;
resume?.();
if (this.#inFlight === 0) {
const finish = this.#finish;
this.#finish = undefined;
finish?.();
}
});
if (this.#inFlight < this.#limit) {
callback();
} else {
this.#resume = callback;
}
}
// eslint-disable-next-line @typescript-eslint/naming-convention -- node's Transform API
_flush(callback) {
if (this.#inFlight === 0) {
callback();
} else {
this.#finish = callback;
}
}
}
/**
* @param {object} [options]
* @param {number} [options.threshold] Skip files smaller than this many bytes.
*/
export default ({ threshold = 0 } = {}) => {
const { size, compress } = sharedPool();
return new ParallelTransform(size, async (file) => {
if (file.isNull()) {
return file;
}
if (file.isStream()) {
file.contents = await readStream(file.contents);
}
if (threshold && file.contents.length < threshold) {
// Passed through unrenamed and uncompressed, as gulp-zopfli-green did.
return file;
}
file.contents = await compress(file.contents);
file.path += EXTENSION;
return file;
});
};
+29 -1
View File
@@ -111,7 +111,17 @@ export default tseslint.config(
"no-bitwise": "error",
"no-console": "error",
"no-restricted-globals": [2, "event"],
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
"no-restricted-syntax": [
"error",
"LabeledStatement",
"WithStatement",
{
selector:
"CallExpression[callee.property.name=/^(push|replace)State$/]",
message:
"Use navigate(), updateHistoryState() or replaceCurrentUrl() from common/navigate. History entries carry the app's own bookkeeping, which a raw pushState/replaceState drops.",
},
],
"wc/no-self-class": "off",
// import-x rules
@@ -222,6 +232,24 @@ export default tseslint.config(
],
},
},
{
// These own history entries themselves: the navigation helpers, the dialog
// stack, the boot paths that run before the app has any state to keep, and
// the tests that fabricate entries to simulate a document load.
files: [
"src/common/navigate.ts",
"src/dialogs/make-dialog-manager.ts",
"src/state/url-sync-mixin.ts",
"src/panels/config/automation/add-automation-element-dialog.ts",
"src/entrypoints/core.ts",
"src/onboarding/**/*.ts",
"cast/**/*.ts",
"test/**/*.ts",
],
rules: {
"no-restricted-syntax": ["error", "LabeledStatement", "WithStatement"],
},
},
{
files: ["src/util/recorder-worklet.js"],
languageOptions: {
-3
View File
@@ -1,4 +1 @@
import { availableParallelism } from "node:os";
import "./build-scripts/gulp/index.mjs";
process.env.UV_THREADPOOL_SIZE = availableParallelism();
+2 -2
View File
@@ -103,7 +103,6 @@
"echarts": "6.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
@@ -145,6 +144,7 @@
"@babel/preset-env": "8.0.2",
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.64.0",
"@lokalise/node-api": "16.3.0",
"@octokit/auth-oauth-device": "8.0.3",
@@ -164,7 +164,7 @@
"@types/leaflet-draw": "1.0.13",
"@types/leaflet.markercluster": "1.5.6",
"@types/lodash.merge": "4.6.9",
"@types/luxon": "3.7.2",
"@types/luxon": "3.7.3",
"@types/qrcode": "1.5.6",
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
+23
View File
@@ -294,3 +294,26 @@ export const formatCallyDateRange = (
return `${startDate}/${endDate}`;
};
/**
* August 2021, for calendar days coming out of cally.
*
* cally hands out calendar days as `Date` objects anchored to UTC midnight, and
* formats its own headings in UTC too. Anything derived from a cally event has
* to be formatted the same way: the resolved time zone shifts the day back for
* negative UTC offsets, which becomes a wrong month — and in January a wrong
* year — whenever the day is the 1st.
*/
export const formatCallyMonthYear = (
dateObj: Date,
locale: FrontendLocaleData
) => formatCallyMonthYearMem(locale.language).format(dateObj);
const formatCallyMonthYearMem = memoizeOne(
(language: string) =>
new Intl.DateTimeFormat(language, {
month: "long",
year: "numeric",
timeZone: "UTC",
})
);
+2 -1
View File
@@ -1,8 +1,9 @@
import type { ReactiveElement } from "lit";
import { updateHistoryState } from "../navigate";
import { throttle } from "../util/throttle";
const throttleReplaceState = throttle((value) => {
history.replaceState({ scrollPosition: value }, "");
updateHistoryState({ scrollPosition: value });
}, 300);
export function restoreScroll(selector: string) {
+59 -39
View File
@@ -1,6 +1,7 @@
import { closeAllDialogs } from "../dialogs/make-dialog-manager";
import { fireEvent } from "./dom/fire_event";
import { mainWindow } from "./dom/get_main_window";
import { currentPath } from "./url/current-path";
declare global {
// for fire event
@@ -17,6 +18,26 @@ export interface NavigateOptions {
// max time to wait for dialogs to close before navigating
const DIALOG_WAIT_TIMEOUT = 500;
/**
* Merge into the current history entry's state, keeping what is already there.
* Entries carry the app's own bookkeeping (`from`, `root`, dialog state), so
* they must never be replaced wholesale.
*/
export const updateHistoryState = (patch: Record<string, unknown>) => {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, ...patch },
""
);
};
/**
* Rewrite the URL of the current history entry without navigating and without
* touching its state. For query parameter cleanup.
*/
export const replaceCurrentUrl = (url: string) => {
mainWindow.history.replaceState(mainWindow.history.state, "", url);
};
/**
* Stash a destination URL in the current history entry's state. If the page
* is refreshed while a dialog is open, urlSyncMixin will navigate to this URL
@@ -24,10 +45,7 @@ const DIALOG_WAIT_TIMEOUT = 500;
* The current URL is not changed.
*/
export const setRefreshUrl = (path: string) => {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, refreshUrl: path },
""
);
updateHistoryState({ refreshUrl: path });
};
/**
@@ -63,37 +81,32 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
}
const replace = options?.replace || false;
if (__DEMO__) {
if (!path.includes("#")) {
// The demo routes with the hash instead of the pathname. Resolve the
// path like the browser would do for pushState, and keep the query
// parameters in the URL query instead of inside the hash.
const url = new URL(
path,
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
);
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
}
if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root
? { root: true }
: (options?.data ?? null),
"",
path
);
} else {
mainWindow.history.pushState(options?.data ?? null, "", path);
}
} else if (replace) {
mainWindow.history.replaceState(
mainWindow.history.state?.root ? { root: true } : (options?.data ?? null),
if (__DEMO__ && !path.includes("#")) {
// The demo routes with the hash instead of the pathname. Resolve the
// path like the browser would do for pushState, and keep the query
// parameters in the URL query instead of inside the hash.
const url = new URL(
path,
`${mainWindow.location.origin}${mainWindow.location.hash.substring(1)}`
);
path = `${mainWindow.location.pathname}${url.search}#${url.pathname}`;
}
const { history } = mainWindow;
if (replace) {
// A replaced entry keeps its predecessor, so it keeps `from`.
const { root, from } = history.state ?? {};
const state = root ? { root: true } : (options?.data ?? null);
history.replaceState(
from === undefined ? state : { ...state, from },
"",
path
);
} else {
mainWindow.history.pushState(options?.data ?? null, "", path);
history.pushState({ ...options?.data, from: currentPath() }, "", path);
}
fireEvent(mainWindow, "location-changed", {
replace,
});
@@ -101,8 +114,17 @@ export const navigate = async (path: string, options?: NavigateOptions) => {
};
/**
* Navigate back in history, with fallback to a default path if no history exists.
* This prevents a user from getting stuck when they navigate directly to a page with no history.
* Whether the previous history entry is a page this app navigated away from.
* `history.length` cannot answer this: a login redirect goes through
* `location.assign`, which leaves /auth/authorize right behind the requested
* page, and going back there would bounce the user out of the app.
*/
export const canGoBack = (): boolean =>
mainWindow.history.state?.from !== undefined;
/**
* Navigate back to the page we came from, falling back to a path when the
* previous entry is not ours (deep link, login redirect, fresh tab).
*/
export const goBack = async (fallbackPath?: string): Promise<void> => {
const canProceed = await ensureDialogsClosed(Date.now());
@@ -110,14 +132,12 @@ export const goBack = async (fallbackPath?: string): Promise<void> => {
return;
}
// Check if we have history to go back to
const { history } = mainWindow;
if (history.length > 1) {
history.back();
// Read after closing dialogs: their history entries are popped by then, so
// this is the state of the page entry.
if (canGoBack()) {
mainWindow.history.back();
return;
}
// No history available, navigate to fallback path
const fallback = fallbackPath || "/";
navigate(fallback, { replace: true });
navigate(fallbackPath || "/", { replace: true });
};
+10
View File
@@ -0,0 +1,10 @@
import { mainWindow } from "../dom/get_main_window";
/**
* The path of the page currently shown by the app. The demo routes with the
* hash instead of the pathname, see navigate().
*/
export const currentPath = (): string =>
__DEMO__
? mainWindow.location.hash.substring(1)
: mainWindow.location.pathname;
+1
View File
@@ -15,6 +15,7 @@ export class HaInputChip extends InputChip {
styles,
css`
:host {
max-width: 100%;
--md-sys-color-primary: var(--primary-text-color);
--md-sys-color-on-surface: var(--primary-text-color);
--md-sys-color-on-surface-variant: var(--primary-text-color);
+21 -37
View File
@@ -9,8 +9,8 @@ import { customElement, property, queryAll, state } from "lit/decorators";
import { firstWeekdayIndex } from "../../common/datetime/first_weekday";
import {
formatCallyDateRange,
formatDateMonth,
formatDateYear,
formatCallyMonthYear,
formatDateMonthYear,
formatISODateOnly,
} from "../../common/datetime/format_date";
import { transform } from "../../common/decorators/transform";
@@ -60,13 +60,16 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
})
private _hassConfig!: HassConfig;
/** used to show month in calendar-range header */
@state() private _pickerMonth?: string;
/** used to show month and year in calendar-range header */
@state() private _pickerMonthYear?: string;
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in calendar-range */
/**
* used for today to navigate focus in calendar-range
*
* Always mirrors the day calendar-range has focused, never cleared: once this
* has held a date, rendering `undefined` over it makes cally fall back to the
* selected range and page the calendar away from where the user is.
*/
@state() private _focusDate?: string;
@state() private _dateValue?: string;
@@ -92,12 +95,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._hassConfig
)
: undefined;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -167,9 +165,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<span class="month-year">${this._pickerMonthYear}</span>
<ha-icon-button
@click=${this._focusToday}
.path=${mdiCalendarToday}
@@ -233,12 +229,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -311,19 +302,12 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
});
}
private _focusChanged(ev: HASSDomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
private _focusChanged(
ev: HASSDomEvent<Date> &
HASSDomTargetEvent<HTMLElementTagNameMap["calendar-range"]>
) {
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
this._focusDate = ev.target.focusedDate;
}
private _handleChange(
@@ -331,7 +315,7 @@ export class DateRangePicker extends MobileAwareMixin(LitElement) {
) {
const dateElement = ev.target as HTMLElementTagNameMap["calendar-range"];
this._dateValue = dateElement.value;
this._focusDate = undefined;
this._focusDate = dateElement.focusedDate;
}
private _clickDateRangeChip(
@@ -6,7 +6,8 @@ import type { HassConfig } from "home-assistant-js-websocket/dist/types";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import {
formatDateMonth,
formatCallyMonthYear,
formatDateMonthYear,
formatDateShort,
formatDateYear,
formatISODateOnly,
@@ -60,13 +61,16 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
dateString: string;
};
/** used to show month in calendar-date header */
@state() private _pickerMonth?: string;
/** used to show month and year in calendar-date header */
@state() private _pickerMonthYear?: string;
/** used to show year in calendar-date header */
@state() private _pickerYear?: string;
/** used for today to navigate focus in cally-calendar-date */
/**
* used for today to navigate focus in cally-calendar-date
*
* Always mirrors the day calendar-date has focused, never cleared: once this
* has held a date, rendering `undefined` over it makes cally fall back to the
* selected value and page the calendar away from where the user is.
*/
@state() private _focusDate?: string;
public connectedCallback() {
@@ -75,12 +79,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
if (this.params) {
const date = valueToDate(this.params.value);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerMonth = formatDateMonth(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -88,7 +87,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
this._value = this.params.value
? {
year: this._pickerYear,
year: formatDateYear(date, this._i18n.locale, this._hassConfig),
title: formatDateShort(date, this._i18n.locale, this._hassConfig),
dateString: formatISODateOnly(
date,
@@ -144,9 +143,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
slot="previous"
></ha-icon-button-prev>
<div class="heading" slot="heading">
<span class="month-year"
>${this._pickerMonth} ${this._pickerYear}</span
>
<span class="month-year">${this._pickerMonthYear}</span>
<ha-icon-button
@click=${this._setToday}
.path=${mdiCalendarToday}
@@ -189,12 +186,7 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
if (setFocusDay) {
this._focusDate = this._value.dateString;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
this._pickerMonthYear = formatDateMonthYear(
date,
this._i18n.locale,
this._hassConfig
@@ -202,19 +194,11 @@ export class HaDialogDatePicker extends DialogMixin<DatePickerDialogParams>(
}
}
private _focusChanged(ev: HASSDomEvent<Date>) {
const date = ev.detail;
this._pickerMonth = formatDateMonth(
date,
this._i18n.locale,
this._hassConfig
);
this._pickerYear = formatDateYear(
date,
this._i18n.locale,
this._hassConfig
);
this._focusDate = undefined;
private _focusChanged(
ev: HASSDomEvent<Date> & HASSDomTargetEvent<CalendarDate>
) {
this._pickerMonthYear = formatCallyMonthYear(ev.detail, this._i18n.locale);
this._focusDate = ev.target.focusedDate;
}
private _clear() {
+30
View File
@@ -0,0 +1,30 @@
import { css, html, LitElement } from "lit";
import type { TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import type { HaFormDividerSchema, HaFormElement } from "./types";
@customElement("ha-form-divider")
export class HaFormDivider extends LitElement implements HaFormElement {
@property({ attribute: false }) public schema!: HaFormDividerSchema;
protected render(): TemplateResult {
return html`<hr />`;
}
static styles = css`
:host {
display: block;
}
hr {
border: none;
border-top: 1px solid var(--ha-color-border-neutral-quiet);
margin: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-form-divider": HaFormDivider;
}
}
@@ -12,9 +12,11 @@ import type { HaDropdownSelectEvent } from "../ha-dropdown";
import "../ha-dropdown-item";
import "../ha-svg-icon";
import "./ha-form";
import "./ha-form-divider";
import type { HaForm } from "./ha-form";
import type {
HaFormDataContainer,
HaFormDividerSchema,
HaFormElement,
HaFormOptionalActionsSchema,
HaFormSchema,
@@ -22,6 +24,11 @@ import type {
const NO_ACTIONS = [];
const DIVIDER = {
name: "",
type: "divider",
} as const satisfies HaFormDividerSchema;
@customElement("ha-form-optional_actions")
export class HaFormOptionalActions extends LitElement implements HaFormElement {
@property({ attribute: false }) public localize?: LocalizeFunc;
@@ -87,7 +94,9 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
schema: readonly HaFormSchema[],
displayActions: string[]
): HaFormSchema[] =>
schema.filter((item) => displayActions.includes(item.name))
schema
.filter((item) => displayActions.includes(item.name))
.flatMap((item) => [DIVIDER, item])
);
public render(): TemplateResult {
@@ -128,6 +137,7 @@ export class HaFormOptionalActions extends LitElement implements HaFormElement {
${
hiddenActions.length > 0
? html`
<ha-form-divider></ha-form-divider>
<ha-dropdown
@wa-select=${this._handleAddAction}
@closed=${stopPropagation}
+1
View File
@@ -20,6 +20,7 @@ type HaFormDataContainerChangedEvent = ValueChangedEvent<HaFormDataContainer>;
const LOAD_ELEMENTS = {
boolean: () => import("./ha-form-boolean"),
constant: () => import("./ha-form-constant"),
divider: () => import("./ha-form-divider"),
float: () => import("./ha-form-float"),
grid: () => import("./ha-form-grid"),
expandable: () => import("./ha-form-expandable"),
+5
View File
@@ -4,6 +4,7 @@ import type { HaDurationData } from "../ha-duration-input";
export type HaFormSchema =
| HaFormConstantSchema
| HaFormDividerSchema
| HaFormStringSchema
| HaFormIntegerSchema
| HaFormFloatSchema
@@ -97,6 +98,10 @@ export interface HaFormConstantSchema extends HaFormBaseSchema {
value?: string;
}
export interface HaFormDividerSchema extends HaFormBaseSchema {
type: "divider";
}
export interface HaFormIntegerSchema extends HaFormBaseSchema {
type: "integer";
default?: HaFormIntegerData;
@@ -187,6 +187,7 @@ export class HaSelectSelector extends LitElement {
.idx=${idx}
@remove=${this._removeItem}
.label=${label}
.title=${label}
selected
>
${
+3 -27
View File
@@ -31,8 +31,6 @@ export class HaToast extends LitElement {
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
0;
@property({ type: Boolean }) public stacked = false;
@query(".toast")
private _toast?: HTMLDivElement;
@@ -150,12 +148,7 @@ export class HaToast extends LitElement {
}
private _showToastPopover(): void {
if (
!this._toast ||
this.stacked ||
!popoverSupported ||
this._isPopoverOpen()
) {
if (!this._toast || !popoverSupported || this._isPopoverOpen()) {
return;
}
@@ -163,12 +156,7 @@ export class HaToast extends LitElement {
}
private _hideToastPopover(): void {
if (
!this._toast ||
this.stacked ||
!popoverSupported ||
!this._isPopoverOpen()
) {
if (!this._toast || !popoverSupported || !this._isPopoverOpen()) {
return;
}
@@ -199,15 +187,12 @@ export class HaToast extends LitElement {
class=${classMap({
toast: true,
active: this._active,
stacked: this.stacked,
visible: this._visible,
})}
style=${styleMap({
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
})}
popover=${ifDefined(
popoverSupported && !this.stacked ? "manual" : undefined
)}
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
>
<span class="message">${this.labelText}</span>
<div class=${classMap({ actions: true, "has-action": hasAction })}>
@@ -268,15 +253,6 @@ export class HaToast extends LitElement {
transform: translate(calc(-50% * var(--scale-direction)), 0);
}
.toast.stacked {
position: static;
transform: translateY(var(--ha-space-2));
}
.toast.stacked.visible {
transform: translateY(0);
}
.toast:not(.active) {
display: none;
}
+6 -10
View File
@@ -40,7 +40,7 @@ import {
getEntityEntryContext,
} from "../../common/entity/context/get_entity_context";
import { shouldHandleRequestSelectedEvent } from "../../common/mwc/handle-request-selected-event";
import { navigate } from "../../common/navigate";
import { navigate, updateHistoryState } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import { withViewTransition } from "../../common/util/view-transition";
@@ -268,16 +268,12 @@ export class MoreInfoDialog extends DirtyStateProviderMixin<
}
private _setView(view: MoreInfoView) {
history.replaceState(
{
...history.state,
dialogParams: {
...history.state?.dialogParams,
view,
},
updateHistoryState({
dialogParams: {
...history.state?.dialogParams,
view,
},
""
);
});
this._currView = view;
}
+19 -14
View File
@@ -4,6 +4,7 @@ import { customElement, eventOptions, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { restoreScroll } from "../common/decorators/restore-scroll";
import type { HASSDomTargetEvent } from "../common/dom/fire_event";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { goBack } from "../common/navigate";
import { sanitizeNavigationPath } from "../common/url/sanitize-navigation-path";
import "../components/ha-icon-button-arrow-prev";
@@ -37,19 +38,14 @@ class HassSubpage extends LitElement {
<div class="toolbar ${classMap({ narrow: this.narrow })}">
<div class="toolbar-content">
${
this.mainPage || history.state?.root
this.mainPage || (!backPath && history.state?.root)
? html`<ha-menu-button></ha-menu-button>`
: backPath
? html`
<ha-icon-button-arrow-prev
href=${backPath}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
}
<div class="main-title">
@@ -79,12 +75,21 @@ class HassSubpage extends LitElement {
this._savedScrollPos = (e.target as HTMLDivElement).scrollTop;
}
private _backTapped(): void {
private _backTapped(ev: MouseEvent): void {
const backPath = sanitizeNavigationPath(this.backPath);
// Middle click, ctrl and cmd open the parent in a new tab, let the anchor
// handle those.
if (backPath && !isNavigationClick(ev)) {
return;
}
if (this.backCallback) {
this.backCallback();
return;
}
goBack();
goBack(backPath);
}
static get styles(): CSSResultGroup {
+17 -13
View File
@@ -175,17 +175,12 @@ export class HassTabsSubpage extends LitElement {
${
this.mainPage || (!backPath && history.state?.root)
? html`<ha-menu-button></ha-menu-button>`
: backPath
? html`
<ha-icon-button-arrow-prev
.href=${backPath}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
: html`
<ha-icon-button-arrow-prev
.href=${backPath}
@click=${this._backTapped}
></ha-icon-button-arrow-prev>
`
}
${
this._narrow || !this.showTabs
@@ -246,12 +241,21 @@ export class HassTabsSubpage extends LitElement {
this._content.focus({ preventScroll: true });
}
private _backTapped(): void {
private _backTapped(ev: MouseEvent): void {
const backPath = sanitizeNavigationPath(this.backPath);
// Middle click, ctrl and cmd open the parent in a new tab, let the anchor
// handle those.
if (backPath && !isNavigationClick(ev)) {
return;
}
if (this.backCallback) {
this.backCallback();
return;
}
goBack();
goBack(backPath);
}
private _isActiveTabPath(tabPath: string, currentPath: string): boolean {
+82 -216
View File
@@ -1,20 +1,7 @@
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import {
customElement,
property,
query,
queryAll,
state,
} from "lit/decorators";
import type {
HASSDomCurrentTargetEvent,
HASSDomEvent,
} from "../common/dom/fire_event";
import { popoverSupported } from "../common/feature-detect/support-popover";
import { html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import type { HASSDomEvent } from "../common/dom/fire_event";
import type { LocalizeKeys } from "../common/translations/localize";
import "../components/ha-button";
import "../components/ha-icon-button";
@@ -23,7 +10,7 @@ import type { ToastClosedEventDetail } from "../components/ha-toast";
import type { HomeAssistant } from "../types";
export interface ShowToastParams {
// Unique ID for updating or closing a specific toast without flickering.
// Unique ID for the toast. If a new toast is shown with the same ID as the previous toast, it will be replaced to avoid flickering.
id?: string;
message:
| string
@@ -47,150 +34,105 @@ export interface ToastActionParams {
| { translationKey: LocalizeKeys; args?: Record<string, string | number> };
}
interface Notification {
key: string;
parameters: ShowToastParams;
}
@customElement("notification-manager")
class NotificationManager extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _notifications: Notification[] = [];
@state() private _parameters?: ShowToastParams;
@query(".stack") private _stack?: HTMLDivElement;
@query("ha-toast")
private _toast!: HTMLElementTagNameMap["ha-toast"] | undefined;
@queryAll("ha-toast")
private _toasts!: NodeListOf<HTMLElementTagNameMap["ha-toast"]>;
private _anonymousId = 0;
private _showDialogId = 0;
public async showDialog(parameters: ShowToastParams) {
if (parameters.duration === 0) {
if (parameters.id) {
await this._closeNotification(`identified-${parameters.id}`);
} else {
const notification = [...this._notifications]
.reverse()
.find(({ parameters: { id } }) => !id);
if (notification) {
await this._closeNotification(notification.key);
}
}
const showId = ++this._showDialogId;
if (!parameters.id || this._parameters?.id !== parameters.id) {
await this._toast?.hide();
}
if (showId !== this._showDialogId) {
return;
}
const normalizedParameters = {
...parameters,
duration:
parameters.duration === undefined ||
(parameters.duration > 0 && parameters.duration <= 4000)
? 4000
: parameters.duration,
};
const key = parameters.id
? `identified-${parameters.id}`
: `anonymous-${++this._anonymousId}`;
const existingIndex = parameters.id
? this._notifications.findIndex(
(notification) => notification.key === key
)
: -1;
if (parameters.duration === 0) {
this._parameters = undefined;
return;
}
this._notifications =
existingIndex === -1
? [...this._notifications, { key, parameters: normalizedParameters }]
: this._notifications.map((notification, index) =>
index === existingIndex
? { key, parameters: normalizedParameters }
: notification
);
this._parameters = parameters;
if (
this._parameters.duration === undefined ||
(this._parameters.duration > 0 && this._parameters.duration <= 4000)
) {
this._parameters.duration = 4000;
}
await this.updateComplete;
this._showStack();
this._getToast(key)?.show();
if (showId !== this._showDialogId) {
return;
}
this._toast?.show();
}
private _toastClosed(
ev: HASSDomEvent<ToastClosedEventDetail> &
HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-toast"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
private _toastClosed(ev: HASSDomEvent<ToastClosedEventDetail>) {
if (ev.detail.reason === "dismiss") {
this._getNotification(key)?.parameters.dismiss?.();
this._parameters?.dismiss?.();
}
this._removeNotification(key);
this._parameters = undefined;
}
protected render() {
if (!this._notifications.length) {
if (!this._parameters) {
return nothing;
}
const bottomOffset = Math.max(
...this._notifications.map(
({ parameters }) => parameters.bottomOffset ?? 0
)
);
return html`
<div
class="stack"
style=${styleMap({
"--notification-stack-bottom-offset": `${bottomOffset}px`,
})}
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
<ha-toast
.labelText=${
typeof this._parameters.message !== "string"
? this.hass.localize(
this._parameters.message.translationKey,
this._parameters.message.args
)
: this._parameters.message
}
.announceText=${
this._parameters.announceMessage
? typeof this._parameters.announceMessage !== "string"
? this.hass.localize(
this._parameters.announceMessage.translationKey,
this._parameters.announceMessage.args
)
: this._parameters.announceMessage
: undefined
}
.timeoutMs=${this._parameters.duration!}
.bottomOffset=${this._parameters.bottomOffset ?? 0}
@toast-closed=${this._toastClosed}
>
${repeat(
this._notifications,
(notification) => notification.key,
({ key, parameters }) => html`
<ha-toast
data-notification-key=${key}
.labelText=${
typeof parameters.message !== "string"
? this.hass.localize(
parameters.message.translationKey,
parameters.message.args
)
: parameters.message
}
.announceText=${
parameters.announceMessage
? typeof parameters.announceMessage !== "string"
? this.hass.localize(
parameters.announceMessage.translationKey,
parameters.announceMessage.args
)
: parameters.announceMessage
: undefined
}
.timeoutMs=${parameters.duration!}
.stacked=${true}
@toast-closed=${this._toastClosed}
>
${this._renderAction(key, parameters.secondaryAction, true)}
${this._renderAction(key, parameters.action, false)}
${
parameters.dismissable
? html`
<ha-icon-button
data-notification-key=${key}
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
slot="dismiss"
@click=${this._dismissClicked}
></ha-icon-button>
`
: nothing
}
</ha-toast>
`
)}
</div>
${this._renderAction(this._parameters.secondaryAction, true)}
${this._renderAction(this._parameters.action, false)}
${
this._parameters?.dismissable
? html`
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
slot="dismiss"
@click=${this._dismissClicked}
></ha-icon-button>
`
: nothing
}
</ha-toast>
`;
}
private _renderAction(
key: string,
action: ToastActionParams | undefined,
secondary: boolean
) {
@@ -199,7 +141,6 @@ class NotificationManager extends LitElement {
}
return html`
<ha-button
data-notification-key=${key}
appearance=${action.primary ? "filled" : "plain"}
size="s"
slot="action"
@@ -214,94 +155,19 @@ class NotificationManager extends LitElement {
`;
}
private _buttonClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-button"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
this._getToast(key)?.hide("action");
this._getNotification(key)?.parameters.action?.action();
private _buttonClicked() {
this._toast?.hide("action");
this._parameters?.action?.action();
}
private _secondaryButtonClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-button"]>
) {
const key = ev.currentTarget.dataset.notificationKey!;
this._getToast(key)?.hide("action");
this._getNotification(key)?.parameters.secondaryAction?.action();
private _secondaryButtonClicked() {
this._toast?.hide("action");
this._parameters?.secondaryAction?.action();
}
private _dismissClicked(
ev: HASSDomCurrentTargetEvent<HTMLElementTagNameMap["ha-icon-button"]>
) {
this._getToast(ev.currentTarget.dataset.notificationKey!)?.hide("dismiss");
private _dismissClicked() {
this._toast?.hide("dismiss");
}
private _getNotification(key: string) {
return this._notifications.find((notification) => notification.key === key);
}
private _getToast(key: string) {
return [...this._toasts].find(
(toast) => toast.dataset.notificationKey === key
);
}
private async _closeNotification(key: string) {
const notification = this._getNotification(key);
if (!notification) {
return;
}
await this._getToast(key)?.hide();
if (this._getNotification(key) === notification) {
this._removeNotification(key);
}
}
private _removeNotification(key: string) {
this._notifications = this._notifications.filter(
(notification) => notification.key !== key
);
if (!this._notifications.length) {
this._hideStack();
}
}
private _showStack() {
if (!popoverSupported || this._stack?.matches(":popover-open")) {
return;
}
this._stack?.showPopover();
}
private _hideStack() {
if (!popoverSupported || !this._stack?.matches(":popover-open")) {
return;
}
this._stack.hidePopover();
}
static override styles = css`
.stack {
position: fixed;
inset-block-start: auto;
inset-inline-end: auto;
inset-block-end: calc(
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4) +
var(--notification-stack-bottom-offset, 0px)
);
inset-inline-start: 50%;
margin: 0;
padding: 0;
border: none;
overflow: visible;
background: transparent;
transform: translateX(calc(-50% * var(--scale-direction)));
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-2);
}
`;
}
declare global {
@@ -644,6 +644,7 @@ class HaConfigAreaPage extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/areas/dashboard"
.header=${html`${
area.icon
? html`<ha-icon
@@ -86,8 +86,6 @@ export class HaConfigAreasDashboard extends LitElement {
@state() private _hierarchy?: AreasFloorHierarchy;
private _searchParms = new URLSearchParams(window.location.search);
private _blockHierarchyUpdate = false;
private _blockHierarchyUpdateTimeout?: number;
@@ -168,9 +166,7 @@ export class HaConfigAreasDashboard extends LitElement {
<hass-tabs-subpage
.hass=${this.hass}
.isWide=${this.isWide}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.tabs=${configSections.areas}
.route=${this.route}
has-fab
@@ -317,7 +317,9 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
color:
automation.state === UNAVAILABLE
? "var(--error-color)"
: "unset",
: automation.state === "on"
? "var(--state-active-color)"
: "unset",
})}
></ha-state-icon>`,
},
@@ -346,19 +348,17 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
maxWidth: "82px",
sortable: true,
groupable: true,
hidden: narrow,
type: "overflow",
title: this.hass.localize("ui.panel.config.automation.picker.state"),
template: (automation) =>
narrow
? automation.formatted_state
: html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
template: (automation) => html`
<ha-switch
@click=${stopPropagation}
@change=${this._handleSwitchToggle}
.automation=${automation}
.checked=${automation.state === "on"}
></ha-switch>
`,
},
actions: {
lastFixed: true,
@@ -443,9 +443,7 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
id="entity_id"
.route=${this.route}
.tabs=${configSections.automations}
@@ -14,7 +14,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import { computeRTL } from "../../../common/util/compute_rtl";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
@@ -110,6 +110,7 @@ export class HaAutomationTrace extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/automation/dashboard"
.header=${title}
.scrollable=${this.narrow}
>
@@ -452,11 +453,7 @@ export class HaAutomationTrace extends LitElement {
if (runId) {
const params = new URLSearchParams(location.search);
params.delete("run_id");
history.replaceState(
null,
"",
`${location.pathname}?${params.toString()}`
);
replaceCurrentUrl(`${location.pathname}?${params.toString()}`);
}
await showAlertDialog(this, {
@@ -11,6 +11,7 @@ import { property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { storage } from "../../../common/decorators/storage";
import { fireEvent } from "../../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../../common/navigate";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import {
extractSearchParam,
@@ -35,8 +36,6 @@ import type HaAutomationSidebar from "./ha-automation-sidebar";
export const SIDEBAR_DEFAULT_WIDTH = 500;
export const PASTED_CONFIG_TOAST_ID = "pasted-config";
export const ManualEditorMixin = <TConfig>(
superClass: Constructor<LitElement>
) => {
@@ -173,11 +172,7 @@ export const ManualEditorMixin = <TConfig>(
}
protected clearParam(param: string) {
window.history.replaceState(
null,
"",
constructUrlCurrentPath(removeSearchParam(param))
);
replaceCurrentUrl(constructUrlCurrentPath(removeSearchParam(param)));
}
protected async openSidebar(ev: CustomEvent<SidebarConfig>) {
@@ -239,7 +234,6 @@ export const ManualEditorMixin = <TConfig>(
this.pastedConfig = undefined;
showToast(this, {
id: PASTED_CONFIG_TOAST_ID,
message: "",
duration: 0,
});
@@ -37,10 +37,7 @@ import "./action/ha-automation-action";
import type HaAutomationAction from "./action/ha-automation-action";
import "./condition/ha-automation-condition";
import type HaAutomationCondition from "./condition/ha-automation-condition";
import {
ManualEditorMixin,
PASTED_CONFIG_TOAST_ID,
} from "./ha-manual-editor-mixin";
import { ManualEditorMixin } from "./ha-manual-editor-mixin";
import { showPasteReplaceDialog } from "./paste-replace-dialog/show-dialog-paste-replace";
import { manualEditorStyles, saveFabStyles } from "./styles";
import "./trigger/ha-automation-trigger";
@@ -434,7 +431,6 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
protected showPastedToastWithUndo() {
showEditorToast(this, {
id: PASTED_CONFIG_TOAST_ID,
message: this.hass.localize(
"ui.panel.config.automation.editor.paste_toast_message"
),
@@ -74,8 +74,6 @@ class HaConfigBackupOverview extends LitElement {
@state() private _config?: BackupConfig;
private _searchParms = new URLSearchParams(window.location.search);
protected willUpdate(changedProperties: PropertyValues<this>): void {
super.willUpdate(changedProperties);
if (changedProperties.has("config") && !this._config) {
@@ -206,9 +204,7 @@ class HaConfigBackupOverview extends LitElement {
return html`
<hass-subpage
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config/system"
}
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.backup.overview.header")}
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../../common/navigate";
import { debounce } from "../../../common/util/debounce";
import { nextRender } from "../../../common/util/render-status";
import "../../../components/ha-alert";
@@ -118,7 +119,7 @@ class HaConfigBackupSettings extends LitElement {
}
private _clearHash() {
history.replaceState(null, "", window.location.pathname);
replaceCurrentUrl(window.location.pathname);
}
protected render() {
@@ -21,6 +21,7 @@ export class CloudForgotPassword extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.header=${this.hass.localize(
"ui.panel.config.cloud.forgot_password.title"
)}
@@ -45,6 +45,7 @@ export class CloudLoginPanel extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
header="Home Assistant Cloud"
>
<ha-dropdown slot="toolbar-icon" @wa-select=${this._handleMenuAction}>
@@ -38,6 +38,7 @@ export class CloudRegister extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config"
.header=${this.hass.localize("ui.panel.config.cloud.register.title")}
>
<div class="content">
@@ -66,8 +66,6 @@ class HaConfigSectionUpdates extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _showSkipped = false;
@state() private _supervisorInfo?: HassioSupervisorInfo;
@@ -155,9 +153,7 @@ class HaConfigSectionUpdates extends LitElement {
return html`
<hass-subpage
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config/system"
}
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.updates.caption")}
@@ -986,6 +986,7 @@ export class HaConfigDevicePage extends LitElement {
return html`<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/devices/dashboard"
.header=${deviceName}
>
<ha-tooltip for="edit-settings-button" slot="toolbar-icon">
@@ -23,7 +23,7 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { navigate } from "../../../common/navigate";
import { navigate, updateHistoryState } from "../../../common/navigate";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
hasRejectedItems,
@@ -778,9 +778,7 @@ export class HaConfigDeviceDashboard extends LitElement {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.tabs=${configSections.devices}
.route=${this.route}
.searchLabel=${this.hass.localize(
@@ -1043,7 +1041,7 @@ export class HaConfigDeviceDashboard extends LitElement {
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
history.replaceState({ filter: this._filter }, "");
updateHistoryState({ filter: this._filter });
}
private _addDevice() {
+1 -7
View File
@@ -78,8 +78,6 @@ class HaConfigEnergy extends LitElement {
@property({ attribute: false }) public route!: Route;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _info?: EnergyInfo;
@state() private _preferences?: EnergyPreferences;
@@ -126,11 +124,7 @@ class HaConfigEnergy extends LitElement {
return html`
<hass-tabs-subpage
.hass=${this.hass}
.backPath=${
this._searchParms.has("historyBack")
? undefined
: "/config/lovelace/dashboards"
}
back-path="/config/lovelace/dashboards"
.route=${this.route}
.tabs=${TABS}
>
@@ -39,6 +39,7 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { updateHistoryState } from "../../../common/navigate";
import { slugify } from "../../../common/string/slugify";
import type { LocalizeFunc } from "../../../common/translations/localize";
import {
@@ -810,9 +811,7 @@ export class HaConfigEntities extends LitElement {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${configSections.devices}
.columns=${this._columns(this.hass.localize, filteredEntities)}
@@ -1246,7 +1245,7 @@ export class HaConfigEntities extends LitElement {
private _handleSearchChange(ev: CustomEvent) {
this._filter = ev.detail.value;
history.replaceState({ filter: this._filter }, "");
updateHistoryState({ filter: this._filter });
}
private _handleSelectionChanged(
@@ -644,9 +644,7 @@ export class HaConfigHelpers extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${configSections.devices}
.searchLabel=${this.hass.localize(
@@ -373,7 +373,11 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
: this._manifest?.documentation;
return html`
<hass-subpage .hass=${this.hass} .narrow=${this.narrow}>
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/integrations/dashboard"
>
${
documentationLink
? html`
@@ -13,7 +13,7 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { navigate } from "../../../common/navigate";
import { navigate, updateHistoryState } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { extractSearchParam } from "../../../common/url/search-params";
import { nextRender } from "../../../common/util/render-status";
@@ -158,8 +158,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
window.location.hash.substring(1)
);
@state() private _searchParams = new URLSearchParams(window.location.search);
@state() private _filter: string = history.state?.filter || "";
@state() private _logInfos?: Record<string, IntegrationLogInfo>;
@@ -503,9 +501,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
return html`
<hass-tabs-subpage
.hass=${this.hass}
.backPath=${
this._searchParams.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${configSections.devices}
has-fab
@@ -862,7 +858,7 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
private _handleSearchChange(ev: InputEvent) {
this._filter = (ev.target as HaInputSearch).value ?? "";
history.replaceState({ filter: this._filter }, "");
updateHistoryState({ filter: this._filter });
}
private async _highlightEntry() {
@@ -95,6 +95,7 @@ export class DHCPConfigPanel extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
back-path="/config/integrations/integration/dhcp"
.columns=${this._columns(this.hass.localize)}
.data=${this._dataWithIds(this._data)}
.noDataText=${this.hass.localize(
@@ -61,7 +61,11 @@ export class MQTTConfigPanel extends LitElement {
protected render(): TemplateResult {
return html`
<hass-subpage .narrow=${this.narrow} .hass=${this.hass}>
<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/integrations/integration/mqtt"
>
<div class="content">
<ha-card
.header=${this.hass.localize("ui.panel.config.mqtt.settings_title")}
@@ -99,6 +99,7 @@ export class SSDPConfigPanel extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
back-path="/config/integrations/integration/ssdp"
.columns=${this._columns(this.hass.localize)}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
@@ -106,6 +106,7 @@ export class ZeroconfConfigPanel extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
back-path="/config/integrations/integration/zeroconf"
.columns=${this._columns(this.hass.localize)}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
@@ -76,6 +76,7 @@ class ZHAAddDevicesPage extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/zha/dashboard"
.header=${this.hass.localize("ui.panel.config.zha.add_device")}
>
<ha-button
@@ -55,6 +55,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/zha/dashboard"
.header=${this.hass.localize(
"ui.panel.config.zha.visualization.header"
)}
@@ -34,7 +34,12 @@ class ZWaveJSConfigEntryPicker extends LitElement {
if (this._configEntries.length === 0) {
return html`
<hass-subpage header="Z-Wave" .narrow=${this.narrow} .hass=${this.hass}>
<hass-subpage
header="Z-Wave"
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/integrations/integration/zwave_js"
>
<div class="content">
<ha-card>
<div class="card-content">
@@ -51,7 +56,12 @@ class ZWaveJSConfigEntryPicker extends LitElement {
}
return html`
<hass-subpage header="Z-Wave" .narrow=${this.narrow} .hass=${this.hass}>
<hass-subpage
header="Z-Wave"
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/integrations/integration/zwave_js"
>
<div class="content">
<ha-card
.header=${this.hass.localize(
@@ -175,6 +175,7 @@ export class HaConfigLovelaceResources extends LitElement {
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
back-path="/config/lovelace/dashboards"
.tabs=${lovelaceResourcesTabs}
.columns=${this._columns(this.hass.language, this.hass.localize)}
.data=${this._resources}
@@ -32,8 +32,6 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
@state() private _repairsIssues: RepairsIssue[] = [];
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _showIgnored = false;
private _getFilteredIssues = memoizeOne(
@@ -77,9 +75,7 @@ class HaConfigRepairsDashboard extends SubscribeMixin(LitElement) {
return html`
<hass-subpage
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config/system"
}
back-path="/config/system"
.hass=${this.hass}
.narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.repairs.caption")}
@@ -459,9 +459,7 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${configSections.automations}
.searchLabel=${this.hass.localize(
+1 -3
View File
@@ -430,9 +430,7 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${configSections.automations}
.searchLabel=${this.hass.localize(
+3 -6
View File
@@ -14,7 +14,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { navigate } from "../../../common/navigate";
import { navigate, replaceCurrentUrl } from "../../../common/navigate";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
@@ -106,6 +106,7 @@ export class HaScriptTrace extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/script/dashboard"
.header=${title}
.scrollable=${this.narrow}
>
@@ -433,11 +434,7 @@ export class HaScriptTrace extends LitElement {
if (runId) {
const params = new URLSearchParams(location.search);
params.delete("run_id");
history.replaceState(
null,
"",
`${location.pathname}?${params.toString()}`
);
replaceCurrentUrl(`${location.pathname}?${params.toString()}`);
}
await showAlertDialog(this, {
@@ -33,10 +33,7 @@ import { documentationUrl } from "../../../util/documentation-url";
import { showEditorToast } from "../automation/editor-toast";
import "../automation/action/ha-automation-action";
import type HaAutomationAction from "../automation/action/ha-automation-action";
import {
ManualEditorMixin,
PASTED_CONFIG_TOAST_ID,
} from "../automation/ha-manual-editor-mixin";
import { ManualEditorMixin } from "../automation/ha-manual-editor-mixin";
import { showPasteReplaceDialog } from "../automation/paste-replace-dialog/show-dialog-paste-replace";
import { manualEditorStyles, saveFabStyles } from "../automation/styles";
import "./ha-script-fields";
@@ -342,7 +339,6 @@ export class HaManualScriptEditor extends ManualEditorMixin<ScriptConfig>(
protected showPastedToastWithUndo() {
showEditorToast(this, {
id: PASTED_CONFIG_TOAST_ID,
message: this.hass.localize(
"ui.panel.config.script.editor.paste_toast_message"
),
@@ -128,6 +128,7 @@ class AssistDevicesPage extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
back-path="/config/voice-assistants/assistants"
.header=${this.hass.localize(
"ui.panel.config.voice_assistants.assistants.pipeline.devices.title"
)}
@@ -51,6 +51,7 @@ export class AssistPipelineDebug extends LitElement {
return html`<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/voice-assistants/debug"
.header=${this.hass.localize(
"ui.panel.config.voice_assistants.debug.header"
)}
@@ -60,6 +60,7 @@ export class AssistPipelineRunDebug extends LitElement {
<hass-subpage
.narrow=${this.narrow}
.hass=${this.hass}
back-path="/config/voice-assistants/assistants"
.header=${this.hass.localize(
"ui.panel.config.voice_assistants.debug.pipeline.header"
)}
@@ -29,8 +29,6 @@ export class HaConfigVoiceAssistantsAssistants extends LitElement {
@property({ attribute: false }) public route!: Route;
private _searchParms = new URLSearchParams(window.location.search);
protected render() {
if (!this.hass) {
return html`<hass-loading-screen></hass-loading-screen>`;
@@ -39,9 +37,7 @@ export class HaConfigVoiceAssistantsAssistants extends LitElement {
return html`
<hass-tabs-subpage
.hass=${this.hass}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${voiceAssistantTabs}
>
@@ -492,9 +492,7 @@ export class VoiceAssistantsExpose extends LitElement {
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.route=${this.route}
.tabs=${voiceAssistantTabs}
.columns=${this._columns(
+1 -5
View File
@@ -57,8 +57,6 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public route!: Route;
@state() private _searchParms = new URLSearchParams(window.location.search);
@state() private _storageItems?: Zone[];
@state() private _stateItems?: HassEntity[];
@@ -248,9 +246,7 @@ export class HaConfigZone extends SubscribeMixin(LitElement) {
<hass-tabs-subpage
.hass=${this.hass}
.route=${this.route}
.backPath=${
this._searchParms.has("historyBack") ? undefined : "/config"
}
back-path="/config"
.tabs=${configSections.areas}
has-fab
>
@@ -135,6 +135,10 @@ export class HuiShortcutBadgeEditor
},
},
},
{
name: "",
type: "divider",
},
{
name: "double_tap_action",
selector: {
@@ -168,6 +168,10 @@ export class HuiShortcutCardEditor
},
},
},
{
name: "",
type: "divider",
},
{
name: "double_tap_action",
selector: {
@@ -199,6 +199,10 @@ export class HuiTileCardEditor
},
context: ACTION_RELATED_CONTEXT,
},
{
name: "",
type: "divider",
},
{
name: "icon_tap_action",
selector: {
+2 -4
View File
@@ -4,7 +4,7 @@ import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { navigate } from "../../common/navigate";
import { navigate, replaceCurrentUrl } from "../../common/navigate";
import type { LocalizeFunc } from "../../common/translations/localize";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import {
@@ -509,9 +509,7 @@ export class LovelacePanel extends LitElement {
};
if ("editMode" in props) {
window.history.replaceState(
null,
"",
replaceCurrentUrl(
constructUrlCurrentPath(
props.editMode
? addSearchParam({ edit: "1" })
+5 -21
View File
@@ -28,7 +28,7 @@ import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { UndoRedoController } from "../../common/controllers/undo-redo-controller";
import { fireEvent } from "../../common/dom/fire_event";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import { goBack, navigate } from "../../common/navigate";
import { goBack, navigate, replaceCurrentUrl } from "../../common/navigate";
import type { LocalizeKeys } from "../../common/translations/localize";
import { constructUrlCurrentPath } from "../../common/url/construct-url";
import { sanitizeNavigationPath } from "../../common/url/sanitize-navigation-path";
@@ -677,11 +677,7 @@ class HUIRoot extends LitElement {
);
private _clearParam(param: string) {
window.history.replaceState(
null,
"",
constructUrlCurrentPath(removeSearchParam(param))
);
replaceCurrentUrl(constructUrlCurrentPath(removeSearchParam(param)));
}
protected firstUpdated(changedProps: PropertyValues<this>) {
@@ -894,22 +890,10 @@ class HUIRoot extends LitElement {
private _goBack(): void {
const views = this.lovelace?.config.views ?? [];
const curViewConfig =
typeof this._curView === "number" ? views[this._curView] : undefined;
// Falling back to the first view only makes sense when it is a real view.
const fallbackPath = views[0]?.subview ? undefined : this.route?.prefix;
const backPath = sanitizeNavigationPath(
curViewConfig?.back_path ?? this.backPath
);
if (backPath) {
navigate(backPath, { replace: true });
} else if (history.length > 1) {
goBack();
} else if (!views[0].subview) {
navigate(this.route!.prefix, { replace: true });
} else {
navigate("/");
}
goBack(this._backPath ?? fallbackPath);
}
private _handleBackClick(ev: MouseEvent): void {
@@ -9,6 +9,7 @@ import { repeat } from "lit/directives/repeat";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { clamp } from "../../../common/number/clamp";
import { updateHistoryState } from "../../../common/navigate";
import "../../../components/ha-icon-button";
import "../../../components/ha-ripple";
import "../../../components/ha-sortable";
@@ -494,10 +495,7 @@ export class SectionsView extends LitElement implements LovelaceViewElement {
this._sidebarTabActive = !this._sidebarTabActive;
// Add sidebar state to history
window.history.replaceState(
{ ...window.history.state, sidebar: this._sidebarTabActive },
""
);
updateHistoryState({ sidebar: this._sidebarTabActive });
// Restore scroll position after view updates
this.updateComplete.then(() => {
@@ -4,6 +4,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { replaceCurrentUrl } from "../../common/navigate";
import { nextRender } from "../../common/util/render-status";
import "../../components/ha-button";
import "../../components/ha-card";
@@ -90,7 +91,7 @@ class HaProfileSectionGeneral extends LitElement {
}
private _clearHash() {
history.replaceState(null, "", window.location.pathname);
replaceCurrentUrl(window.location.pathname);
}
protected render(): TemplateResult {
+1 -9
View File
@@ -13,9 +13,6 @@ import { showToast } from "../util/toast";
import type { HassBaseEl } from "./hass-base-mixin";
import { navigate } from "../common/navigate";
const CONNECTION_LOST_TOAST_ID = "connection-lost";
const SERVER_STARTUP_TOAST_ID = "server-startup";
export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
class extends superClass {
private _subscribedBootstrapIntegrations?: Promise<UnsubscribeFunc>;
@@ -37,7 +34,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
if (oldHass?.config?.state !== this.hass!.config.state) {
if (this.hass!.config.state === STATE_NOT_RUNNING) {
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
message:
this.hass!.localize("ui.notification_toast.starting") ||
"Home Assistant is starting. Not everything will be available until it is finished.",
@@ -61,7 +57,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
) {
this._unsubscribeBootstrapIntegrations();
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
message: this.hass!.localize("ui.notification_toast.started"),
duration: 5000,
});
@@ -100,7 +95,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
return;
}
showToast(this, {
id: CONNECTION_LOST_TOAST_ID,
message: "",
duration: 0,
});
@@ -112,7 +106,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
this._disconnectedTimeout = window.setTimeout(() => {
this._disconnectedTimeout = undefined;
showToast(this, {
id: CONNECTION_LOST_TOAST_ID,
message: this.hass!.localize("ui.notification_toast.connection_lost"),
duration: -1,
dismissable: false,
@@ -127,7 +120,6 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
if (Object.keys(message).length === 0) {
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
message:
this.hass!.localize("ui.notification_toast.wrapping_up_startup") ||
`Wrapping up startup. Not everything will be available until it is finished.`,
@@ -150,7 +142,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
)[0][0];
showToast(this, {
id: SERVER_STARTUP_TOAST_ID,
id: "integration_starting",
message:
this.hass!.localize("ui.notification_toast.integration_starting", {
integration: domainToName(this.hass!.localize, integration),
+2 -4
View File
@@ -2,6 +2,7 @@
import type { ReactiveElement, PropertyValues } from "lit";
import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { updateHistoryState } from "../common/navigate";
import { closeLastDialog } from "../dialogs/make-dialog-manager";
import type { ProvideHassElement } from "../mixins/provide-hass-lit-mixin";
import type { Constructor } from "../types";
@@ -20,10 +21,7 @@ export const urlSyncMixin = <
public connectedCallback(): void {
super.connectedCallback();
if (mainWindow.history.length === 1) {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, root: true },
""
);
updateHistoryState({ root: true });
}
mainWindow.addEventListener("popstate", this._popstateChangeListener);
}
+152
View File
@@ -0,0 +1,152 @@
/**
* @vitest-environment node
*/
import { Buffer } from "node:buffer";
import process from "node:process";
import { Readable } from "node:stream";
import gfxZopfli from "@gfx/zopfli";
import { describe, expect, it } from "vitest";
// Keep the pool small; it is created once, on the first factory call.
process.env.ZOPFLI_WORKERS = "2";
const { default: zopfli } = await import("../../build-scripts/zopfli.mjs");
const THRESHOLD = 150;
// Stand-in for the vinyl files gulp.src yields in buffer mode.
const file = (path, contents) => ({
path,
contents,
isNull() {
return this.contents === null;
},
isStream() {
return this.contents instanceof Readable;
},
});
const run = (files, options = { threshold: THRESHOLD }) =>
new Promise((resolve, reject) => {
const stream = zopfli(options);
const out = [];
stream.on("data", (result) => out.push(result));
stream.on("error", reject);
stream.on("end", () => resolve(out));
files.forEach((entry) => stream.write(entry));
stream.end();
});
// What the old in-process plugin did, for byte-for-byte comparison.
const gzipInProcess = (contents) =>
new Promise((resolve, reject) => {
gfxZopfli.gzip(contents, {}, (error, result) =>
error ? reject(error) : resolve(Buffer.from(result))
);
});
const filler = (length) => Buffer.alloc(length, "a");
describe("zopfli worker pool", () => {
it("appends .gz and matches in-process zopfli byte for byte", async () => {
const contents = filler(4096);
const [result] = await run([file("/out/app.js", contents)]);
expect(result.path).toBe("/out/app.js.gz");
expect(result.contents).toEqual(await gzipInProcess(contents));
});
it("returns contents as a Buffer, which vinyl requires", async () => {
const [result] = await run([file("/out/app.js", filler(1024))]);
expect(Buffer.isBuffer(result.contents)).toBe(true);
});
it("appends to the path rather than replacing the extension", async () => {
const [result] = await run([file("/out/nested/chunk.min.js", filler(500))]);
expect(result.path).toBe("/out/nested/chunk.min.js.gz");
});
it("passes files under the threshold through untouched", async () => {
const contents = filler(THRESHOLD - 1);
const [result] = await run([file("/out/tiny.js", contents)]);
expect(result.path).toBe("/out/tiny.js");
expect(result.contents).toBe(contents);
});
it("compresses a file exactly at the threshold", async () => {
const [result] = await run([file("/out/edge.js", filler(THRESHOLD))]);
expect(result.path).toBe("/out/edge.js.gz");
});
it("compresses everything when no threshold is given", async () => {
const [result] = await run([file("/out/tiny.js", filler(1))], {});
expect(result.path).toBe("/out/tiny.js.gz");
});
it("passes null files through", async () => {
const [result] = await run([file("/out/adirectory", null)]);
expect(result.path).toBe("/out/adirectory");
expect(result.contents).toBeNull();
});
it("compresses only the requested slice of a pooled buffer", async () => {
// Small reads share a pooled ArrayBuffer, so contents often start at a
// non-zero byteOffset into a much larger allocation.
const pool = Buffer.alloc(8192, "z");
const contents = pool.subarray(1000, 1000 + 512);
contents.fill("a");
const [result] = await run([file("/out/pooled.js", contents)]);
expect(result.contents).toEqual(await gzipInProcess(filler(512)));
});
it("buffers stream-mode contents", async () => {
const contents = filler(600);
const [result] = await run([
file("/out/streamed.js", Readable.from([contents])),
]);
expect(result.path).toBe("/out/streamed.js.gz");
expect(result.contents).toEqual(await gzipInProcess(contents));
});
it("handles more files than the pool has workers", async () => {
const count = 25;
const files = Array.from({ length: count }, (_, index) =>
file(`/out/chunk${index}.js`, filler(200 + index))
);
const results = await run(files);
expect(results).toHaveLength(count);
expect(new Set(results.map((result) => result.path)).size).toBe(count);
await Promise.all(
results.map(async (result) => {
const index = Number(result.path.match(/chunk(\d+)/)[1]);
expect(result.contents).toEqual(
await gzipInProcess(filler(200 + index))
);
})
);
});
it("serves two concurrent streams from the same pool", async () => {
const [first, second] = await Promise.all([
run([file("/out/a.js", filler(300))]),
run([file("/out/b.js", filler(400))]),
]);
expect(first[0].path).toBe("/out/a.js.gz");
expect(second[0].path).toBe("/out/b.js.gz");
expect(first[0].contents).toEqual(await gzipInProcess(filler(300)));
expect(second[0].contents).toEqual(await gzipInProcess(filler(400)));
});
});
+40
View File
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
formatCallyMonthYear,
formatDate,
formatDateWeekdayDay,
formatDateShort,
@@ -263,4 +264,43 @@ describe("formatDate", () => {
).toBe("Sat");
});
});
describe("formatCallyMonthYear", () => {
// How cally hands out a calendar day: a Date anchored to UTC midnight
const julyFirst = new Date(Date.UTC(2026, 6, 1));
const januaryFirst = new Date(Date.UTC(2026, 0, 1));
// demoConfig.time_zone is America/Los_Angeles, so this resolves to a
// negative UTC offset whatever zone the tests run in
const locale = {
language: "en",
number_format: NumberFormat.language,
time_format: TimeFormat.language,
date_format: DateFormat.language,
time_zone: TimeZone.server,
first_weekday: FirstWeekday.language,
};
it("Formats in UTC rather than the resolved time zone", () => {
// A time zone aware formatter reads UTC midnight on the 1st as the
// previous month, which desyncs the header from the calendar grid
expect(formatDateMonthYear(julyFirst, locale, demoConfig)).toBe(
"June 2026"
);
expect(formatCallyMonthYear(julyFirst, locale)).toBe("July 2026");
});
it("Keeps the year on a January page", () => {
expect(formatDateMonthYear(januaryFirst, locale, demoConfig)).toBe(
"December 2025"
);
expect(formatCallyMonthYear(januaryFirst, locale)).toBe("January 2026");
});
it("Orders month and year by locale", () => {
expect(
formatCallyMonthYear(julyFirst, { ...locale, language: "ja" })
).toBe("2026年7月");
});
});
});
+88
View File
@@ -0,0 +1,88 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { canGoBack, goBack, navigate } from "../../src/common/navigate";
// navigate() closes open dialogs before touching history.
vi.mock("../../src/dialogs/make-dialog-manager", () => ({
closeAllDialogs: vi.fn(async () => true),
}));
// Sets up a raw entry the way a document load does, which is exactly what the
// helpers in common/navigate refuse to produce.
const setEntry = (path: string, state: unknown = null) => {
window.history.replaceState(state, "", path);
};
describe("navigate", () => {
beforeEach(() => {
setEntry("/config");
});
it("stamps the path we came from on pushed entries", async () => {
await navigate("/config/devices/dashboard");
expect(window.location.pathname).toEqual("/config/devices/dashboard");
expect(window.history.state).toMatchObject({ from: "/config" });
});
it("keeps caller data alongside the stamp", async () => {
await navigate("/config/areas", { data: { scrollPosition: 42 } });
expect(window.history.state).toMatchObject({
scrollPosition: 42,
from: "/config",
});
});
it("keeps the stamp when replacing, the predecessor is unchanged", async () => {
await navigate("/config/cloud");
await navigate("/config/cloud/account", { replace: true });
expect(window.location.pathname).toEqual("/config/cloud/account");
expect(window.history.state).toMatchObject({ from: "/config" });
});
it("does not stamp an entry the app did not push", () => {
expect(window.history.state).toBeNull();
expect(canGoBack()).toBe(false);
});
});
describe("goBack", () => {
beforeEach(() => {
setEntry("/config/cloud/remote");
vi.restoreAllMocks();
});
it("goes back when we came from another page in the app", async () => {
const back = vi
.spyOn(window.history, "back")
.mockImplementation(() => undefined);
await navigate("/config/cloud/remote");
await goBack("/config/cloud/account");
expect(back).toHaveBeenCalledOnce();
});
it("falls back to the given path when the previous entry is not ours", async () => {
// A login redirect lands on the page through location.assign, leaving
// /auth/authorize behind: going back there would leave the app.
const back = vi
.spyOn(window.history, "back")
.mockImplementation(() => undefined);
await goBack("/config/cloud/account");
expect(back).not.toHaveBeenCalled();
expect(window.location.pathname).toEqual("/config/cloud/account");
});
it("falls back to the root when no path is given", async () => {
vi.spyOn(window.history, "back").mockImplementation(() => undefined);
await goBack();
expect(window.location.pathname).toEqual("/");
});
});
-13
View File
@@ -118,19 +118,6 @@ describe("ha-toast", () => {
).toBe(true);
});
it("shows as part of a stack without becoming a popover", async () => {
const element = await mountToast({ stacked: true });
await showToast(element);
expect(
element.shadowRoot!.querySelector(".toast")!.hasAttribute("popover")
).toBe(false);
expect(
element.shadowRoot!.querySelector(".toast")!.classList.contains("visible")
).toBe(true);
});
it.each(["action", "dismiss", "programmatic"] as const)(
"reports a %s close reason",
async (reason) => {
+2 -2
View File
@@ -28,7 +28,7 @@ afterEach(() => {
describe("hass-subpage back path", () => {
it("links to a path on the current origin", async () => {
const backButton = await mount("/config/system");
expect(backButton!.getAttribute("href")).toEqual("/config/system");
expect(backButton!.href).toEqual("/config/system");
});
// eslint-disable-next-line no-script-url
@@ -36,7 +36,7 @@ describe("hass-subpage back path", () => {
"does not link to %s",
async (backPath) => {
const backButton = await mount(backPath);
expect(backButton!.hasAttribute("href")).toBe(false);
expect(backButton!.href).toBeUndefined();
}
);
});
+27 -144
View File
@@ -60,6 +60,14 @@ const mountManager = async () => {
return host.shadowRoot!.querySelector("notification-manager")!;
};
const deferred = () => {
let resolve: () => void;
const promise = new Promise<void>((res) => {
resolve = res;
});
return { promise, resolve: resolve! };
};
afterEach(() => {
host?.remove();
host = undefined;
@@ -73,12 +81,9 @@ describe("notification-manager", () => {
await manager.showDialog({ message: "Configuration saved" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const stack = manager.shadowRoot!.querySelector<HTMLElement>(".stack")!;
expect(toast.labelText).toBe("Configuration saved");
expect(toast.timeoutMs).toBe(4000);
expect(
stack.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("0px");
expect(toast.bottomOffset).toBe(0);
expect(toast.show).toHaveBeenCalledOnce();
});
@@ -93,34 +98,21 @@ describe("notification-manager", () => {
});
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const stack = manager.shadowRoot!.querySelector<HTMLElement>(".stack")!;
expect(toast.timeoutMs).toBe(-1);
expect(
stack.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("16px");
expect(toast.bottomOffset).toBe(16);
manager.shadowRoot!.querySelector<HTMLElement>("ha-icon-button")!.click();
expect(toast.hide).toHaveBeenCalledWith("dismiss");
});
it("clears the latest anonymous notification when duration is zero without an ID", async () => {
it("clears the current notification when duration is zero", async () => {
const manager = await mountManager();
await manager.showDialog({ message: "First message", duration: -1 });
await manager.showDialog({
id: "identified",
message: "Identified message",
duration: -1,
});
await manager.showDialog({ message: "Second message", duration: -1 });
await manager.showDialog({ message: "", duration: 0 });
await manager.updateComplete;
expect(
[...manager.shadowRoot!.querySelectorAll("ha-toast")].map(
(toast) => toast.labelText
)
).toEqual(["First message", "Identified message"]);
expect(manager.shadowRoot!.querySelector("ha-toast")).toBeNull();
});
it.each([
@@ -198,32 +190,6 @@ describe("notification-manager", () => {
expect(primary).toHaveBeenCalledOnce();
});
it("invokes the action belonging to the selected stacked toast", async () => {
const manager = await mountManager();
const firstAction = vi.fn();
const secondAction = vi.fn();
await manager.showDialog({
id: "first",
message: "First",
action: { action: firstAction, text: "First action" },
duration: -1,
});
await manager.showDialog({
id: "second",
message: "Second",
action: { action: secondAction, text: "Second action" },
duration: -1,
});
manager.shadowRoot!.querySelectorAll("ha-button")[1].click();
expect(secondAction).toHaveBeenCalledOnce();
expect(firstAction).not.toHaveBeenCalled();
expect(
manager.shadowRoot!.querySelectorAll("ha-toast")[1].hide
).toHaveBeenCalledWith("action");
});
it("keeps a same-ID toast visible while replacing its content", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "status", message: "First" });
@@ -236,114 +202,31 @@ describe("notification-manager", () => {
expect(toast.labelText).toBe("Second");
});
it("stacks toasts with different IDs", async () => {
it("hides a toast before replacing it with a different ID", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
await manager.showDialog({ id: "second", message: "Second" });
const toasts = manager.shadowRoot!.querySelectorAll("ha-toast");
expect(toasts).toHaveLength(2);
expect([...toasts].map((toast) => toast.labelText)).toEqual([
"First",
"Second",
]);
});
it("uses the largest bottom offset for the notification stack", async () => {
const manager = await mountManager();
await manager.showDialog({
id: "first",
message: "First",
bottomOffset: 16,
});
await manager.showDialog({
id: "second",
message: "Second",
bottomOffset: 48,
});
expect(
manager
.shadowRoot!.querySelector<HTMLElement>(".stack")!
.style.getPropertyValue("--notification-stack-bottom-offset")
).toBe("48px");
});
it("closes only the toast matching a duration-zero ID", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "first", message: "First" });
await manager.showDialog({ id: "second", message: "Second" });
await manager.showDialog({ id: "first", message: "", duration: 0 });
await manager.updateComplete;
const toasts = manager.shadowRoot!.querySelectorAll("ha-toast");
expect(toasts).toHaveLength(1);
expect(toasts[0].labelText).toBe("Second");
});
it("keeps a same-ID update that arrives while the previous toast closes", async () => {
const manager = await mountManager();
await manager.showDialog({ id: "status", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
let finishHide: () => void;
vi.mocked(toast.hide).mockImplementation(
() =>
new Promise<void>((resolve) => {
finishHide = resolve;
})
);
vi.mocked(toast.hide).mockClear();
const closing = manager.showDialog({
id: "status",
message: "",
duration: 0,
});
await Promise.resolve();
await manager.showDialog({ id: "status", message: "Second" });
finishHide!();
await closing;
await manager.updateComplete;
await manager.showDialog({ id: "second", message: "Second" });
const remainingToast = manager.shadowRoot!.querySelector("ha-toast")!;
expect(remainingToast.labelText).toBe("Second");
expect(toast.hide).toHaveBeenCalledOnce();
expect(toast.labelText).toBe("Second");
});
it("keeps a frontend update visible throughout server startup", async () => {
it("ignores a stale replacement after a newer notification starts", async () => {
const manager = await mountManager();
await manager.showDialog({
id: "frontend-update-available",
message: "Frontend update available",
duration: -1,
});
await manager.showDialog({
id: "server-startup",
message: "Home Assistant is starting",
duration: -1,
});
await manager.showDialog({
id: "server-startup",
message: "Starting recorder",
duration: -1,
});
await manager.showDialog({ id: "first", message: "First" });
const toast = manager.shadowRoot!.querySelector("ha-toast")!;
const delayedHide = deferred();
vi.mocked(toast.hide).mockReturnValueOnce(delayedHide.promise);
expect(
[...manager.shadowRoot!.querySelectorAll("ha-toast")].map(
(toast) => toast.labelText
)
).toEqual(["Frontend update available", "Starting recorder"]);
const staleShow = manager.showDialog({ id: "second", message: "Second" });
await manager.showDialog({ id: "third", message: "Third" });
delayedHide.resolve();
await staleShow;
await manager.showDialog({
id: "server-startup",
message: "Home Assistant started",
duration: 5000,
});
expect(
[...manager.shadowRoot!.querySelectorAll("ha-toast")].map(
(toast) => toast.labelText
)
).toEqual(["Frontend update available", "Home Assistant started"]);
expect(toast.labelText).toBe("Third");
});
it("clears rendered state after the toast closes", async () => {
@@ -4,7 +4,6 @@ import "../../../../src/panels/config/script/manual-script-editor";
import type { HaManualScriptEditor } from "../../../../src/panels/config/script/manual-script-editor";
import { createMockHass } from "../../../fixtures/hass";
import { showPasteReplaceDialog } from "../../../../src/panels/config/automation/paste-replace-dialog/show-dialog-paste-replace";
import { PASTED_CONFIG_TOAST_ID } from "../../../../src/panels/config/automation/ha-manual-editor-mixin";
const pasteCases = [
{
@@ -164,8 +163,6 @@ describe("manual automation paste", () => {
once: true,
});
});
const notification = vi.fn();
el.addEventListener("hass-notification", notification);
// Call the protected method through `any` to avoid full DOM lifecycle.
await (el as any).handlePaste(makePasteEvent(paste));
@@ -174,9 +171,6 @@ describe("manual automation paste", () => {
const ev = await valueChanged;
expect(ev.detail.value).toEqual(expected);
expect(notification.mock.calls[0][0].detail.id).toBe(
PASTED_CONFIG_TOAST_ID
);
}
);
});
+9 -56
View File
@@ -2945,7 +2945,7 @@ __metadata:
languageName: node
linkType: hard
"@gfx/zopfli@npm:^1.0.15":
"@gfx/zopfli@npm:1.0.15":
version: 1.0.15
resolution: "@gfx/zopfli@npm:1.0.15"
dependencies:
@@ -5641,10 +5641,10 @@ __metadata:
languageName: node
linkType: hard
"@types/luxon@npm:3.7.2":
version: 3.7.2
resolution: "@types/luxon@npm:3.7.2"
checksum: 10/6634ebaceeec84d39cbb456d4db358481bc2ba7d05df47890a4f2d235100c29a52f621e1401f016747093093fa86c117bef01446199e0b1af3bcdcee47a57b1f
"@types/luxon@npm:3.7.3":
version: 3.7.3
resolution: "@types/luxon@npm:3.7.3"
checksum: 10/f097bd3f7c47ae766928e0fa496ec13fe4d72c42b29afa4bed457775ce4fb4d8574ccccc0c974bf8915d1e6a7e226fa6efb989905786c4c9c1714754f74e8a17
languageName: node
linkType: hard
@@ -6744,13 +6744,6 @@ __metadata:
languageName: node
linkType: hard
"any-promise@npm:^1.1.0":
version: 1.3.0
resolution: "any-promise@npm:1.3.0"
checksum: 10/6737469ba353b5becf29e4dc3680736b9caa06d300bda6548812a8fee63ae7d336d756f88572fa6b5219aed36698d808fa55f62af3e7e6845c7a1dc77d240edb
languageName: node
linkType: hard
"anymatch@npm:^3.1.3, anymatch@npm:~3.1.2":
version: 3.1.3
resolution: "anymatch@npm:3.1.3"
@@ -7328,7 +7321,7 @@ __metadata:
languageName: node
linkType: hard
"bytes@npm:3.1.2, bytes@npm:^3.1.2":
"bytes@npm:3.1.2":
version: 3.1.2
resolution: "bytes@npm:3.1.2"
checksum: 10/a10abf2ba70c784471d6b4f58778c0beeb2b5d405148e66affa91f23a9f13d07603d0a0354667310ae1d6dc141474ffd44e2a074be0f6e2254edb8fc21445388
@@ -9773,19 +9766,6 @@ __metadata:
languageName: node
linkType: hard
"gulp-zopfli-green@npm:7.0.0":
version: 7.0.0
resolution: "gulp-zopfli-green@npm:7.0.0"
dependencies:
"@gfx/zopfli": "npm:^1.0.15"
bytes: "npm:^3.1.2"
plugin-error: "npm:^2.0.1"
stream-to-array: "npm:^2.3.0"
through2: "npm:^4.0.2"
checksum: 10/25db42b9a8f6395847e3cc06c548c3f3d51c84606166f18de71d91d505e7293176b45b4b78b7e0b1566c26263fe0dc212e56d2b367461c3d712bee9d22d5224f
languageName: node
linkType: hard
"gulp@npm:5.0.1":
version: 5.0.1
resolution: "gulp@npm:5.0.1"
@@ -9919,6 +9899,7 @@ __metadata:
"@fullcalendar/list": "npm:6.1.21"
"@fullcalendar/luxon3": "npm:6.1.21"
"@fullcalendar/timegrid": "npm:6.1.21"
"@gfx/zopfli": "npm:1.0.15"
"@home-assistant/webawesome": "npm:3.7.0-ha.0"
"@html-eslint/eslint-plugin": "npm:0.64.0"
"@lezer/highlight": "npm:1.2.3"
@@ -9956,7 +9937,7 @@ __metadata:
"@types/leaflet-draw": "npm:1.0.13"
"@types/leaflet.markercluster": "npm:1.5.6"
"@types/lodash.merge": "npm:4.6.9"
"@types/luxon": "npm:3.7.2"
"@types/luxon": "npm:3.7.3"
"@types/qrcode": "npm:1.5.6"
"@types/sortablejs": "npm:1.15.9"
"@types/tar": "npm:7.0.87"
@@ -10001,7 +9982,6 @@ __metadata:
gulp-brotli: "npm:3.0.0"
gulp-json-transform: "npm:0.5.0"
gulp-rename: "npm:2.1.0"
gulp-zopfli-green: "npm:7.0.0"
hls.js: "npm:1.6.16"
home-assistant-js-websocket: "npm:9.6.0"
html-minifier-terser: "npm:7.2.0"
@@ -12784,15 +12764,6 @@ __metadata:
languageName: node
linkType: hard
"plugin-error@npm:^2.0.1":
version: 2.0.1
resolution: "plugin-error@npm:2.0.1"
dependencies:
ansi-colors: "npm:^1.0.1"
checksum: 10/9a4f91461cd24cce401112098969991d7aa6b4c94f78e0381234280c07da779570a8b21ab143292b534ec0117c09705a67e5d756c1c303d4706fdd7f861bf5bc
languageName: node
linkType: hard
"pngjs@npm:^3.0.0":
version: 3.4.0
resolution: "pngjs@npm:3.4.0"
@@ -13006,7 +12977,7 @@ __metadata:
languageName: node
linkType: hard
"readable-stream@npm:2 || 3, readable-stream@npm:3, readable-stream@npm:^3.4.0":
"readable-stream@npm:2 || 3, readable-stream@npm:^3.4.0":
version: 3.6.2
resolution: "readable-stream@npm:3.6.2"
dependencies:
@@ -14194,15 +14165,6 @@ __metadata:
languageName: node
linkType: hard
"stream-to-array@npm:^2.3.0":
version: 2.3.0
resolution: "stream-to-array@npm:2.3.0"
dependencies:
any-promise: "npm:^1.1.0"
checksum: 10/7feaf63b38399b850615e6ffcaa951e96e4c8f46745dbce4b553a94c5dc43966933813747014935a3ff97793e7f30a65270bde19f82b2932871a1879229a77cf
languageName: node
linkType: hard
"streamx@npm:^2.12.0, streamx@npm:^2.12.5, streamx@npm:^2.13.2, streamx@npm:^2.14.0":
version: 2.28.0
resolution: "streamx@npm:2.28.0"
@@ -14609,15 +14571,6 @@ __metadata:
languageName: node
linkType: hard
"through2@npm:^4.0.2":
version: 4.0.2
resolution: "through2@npm:4.0.2"
dependencies:
readable-stream: "npm:3"
checksum: 10/72c246233d9a989bbebeb6b698ef0b7b9064cb1c47930f79b25d87b6c867e075432811f69b7b2ac8da00ca308191c507bdab913944be8019ac43b036ce88f6ba
languageName: node
linkType: hard
"time-stamp@npm:^1.0.0":
version: 1.1.0
resolution: "time-stamp@npm:1.1.0"