fix: stabilize narrow-window telemetry UI
Collect bandwidth history for the full running session, keep responsive service and status labels readable, repair nested menu overflow, and correct semantic state colors. Update active queue counters immediately for terminal items, add localized integer formatting, eliminate progress label overlap, and cover the regressions with focused tests.
This commit is contained in:
@@ -2,6 +2,29 @@
|
|||||||
|
|
||||||
All notable changes to Multi-Debrid Downloader are documented in this file.
|
All notable changes to Multi-Debrid Downloader are documented in this file.
|
||||||
|
|
||||||
|
## [2.0.16] - 2026-08-10
|
||||||
|
|
||||||
|
### Downloads and telemetry
|
||||||
|
|
||||||
|
- Started bandwidth-history collection with the active download session instead of waiting for the Statistics view to be opened.
|
||||||
|
- Kept the latest 60 seconds of speed samples available when Statistics is opened later.
|
||||||
|
- Updated the sidebar link counter immediately as individual files finish, fail, or leave the active queue.
|
||||||
|
- Added locale-aware thousands separators to package, link, and hoster counters.
|
||||||
|
|
||||||
|
### Interface fixes
|
||||||
|
|
||||||
|
- Kept nested application-menu entries visible in narrow windows without introducing a horizontal scrollbar.
|
||||||
|
- Opened nested right-side menus toward the available left side of the application frame.
|
||||||
|
- Changed enabled settings switches to the semantic success color.
|
||||||
|
- Changed online file indicators to the semantic success color.
|
||||||
|
- Removed overlapping light text fragments from the dark-on-green progress labels and strengthened their weight.
|
||||||
|
- Made service and status labels react to their actual column width, with compact labels and complete accessible names and tooltips.
|
||||||
|
- Shortened Mega-Debrid service labels in narrow columns while preserving the full account description as a tooltip.
|
||||||
|
|
||||||
|
### Reliability and testing
|
||||||
|
|
||||||
|
- Added regression coverage for background bandwidth sampling, 60-second history trimming, nested menu overflow, enabled switch colors, progress-label clipping, responsive service/status cells, immediate queue counts, and localized sidebar counters.
|
||||||
|
|
||||||
## [2.0.15] - 2026-08-10
|
## [2.0.15] - 2026-08-10
|
||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.15",
|
"version": "2.0.16",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.15",
|
"version": "2.0.16",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"adm-zip": "0.6.0",
|
"adm-zip": "0.6.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "2.0.15",
|
"version": "2.0.16",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
+20
-31
@@ -90,7 +90,7 @@ import {
|
|||||||
StatisticsSidebarStatus,
|
StatisticsSidebarStatus,
|
||||||
type StatisticsViewActions
|
type StatisticsViewActions
|
||||||
} from "./views/statistics/StatisticsView";
|
} from "./views/statistics/StatisticsView";
|
||||||
import { buildDownloadsViewModel, getDownloadQueueTotalBytes, getDownloadSpeedBps, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
|
import { buildDownloadsViewModel, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
|
||||||
import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
|
import { downloadColumnDefinitions, type DownloadSortColumn } from "./views/downloads/DownloadsTable";
|
||||||
import { beginDownloadColumnDrag, clearDownloadColumnDrag, settleDownloadColumnDrag, updateDownloadColumnDrag, type DownloadColumnDragSession } from "./views/downloads/column-drag";
|
import { beginDownloadColumnDrag, clearDownloadColumnDrag, settleDownloadColumnDrag, updateDownloadColumnDrag, type DownloadColumnDragSession } from "./views/downloads/column-drag";
|
||||||
import {
|
import {
|
||||||
@@ -1316,17 +1316,26 @@ export function readBandwidthChartPalette(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function appendBandwidthSample(
|
||||||
|
history: { time: number; speed: number }[],
|
||||||
|
speed: number,
|
||||||
|
now = Date.now()
|
||||||
|
): { time: number; speed: number }[] {
|
||||||
|
const next = [...history, { time: now, speed: Number.isFinite(speed) ? Math.max(0, speed) : 0 }];
|
||||||
|
const cutoff = now - 60000;
|
||||||
|
const firstVisible = next.findIndex((point) => point.time >= cutoff);
|
||||||
|
return firstVisible > 0 ? next.slice(firstVisible) : next;
|
||||||
|
}
|
||||||
|
|
||||||
interface BandwidthChartProps {
|
interface BandwidthChartProps {
|
||||||
items: Record<string, DownloadItem>;
|
|
||||||
running: boolean;
|
running: boolean;
|
||||||
paused: boolean;
|
paused: boolean;
|
||||||
speedHistoryRef: React.MutableRefObject<{ time: number; speed: number }[]>;
|
speedHistoryRef: React.MutableRefObject<{ time: number; speed: number }[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BandwidthChart = memo(function BandwidthChart({ items, running, paused, speedHistoryRef }: BandwidthChartProps): ReactElement {
|
const BandwidthChart = memo(function BandwidthChart({ running, paused, speedHistoryRef }: BandwidthChartProps): ReactElement {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const lastUpdateRef = useRef<number>(0);
|
|
||||||
|
|
||||||
const animationFrameRef = useRef<number>(0);
|
const animationFrameRef = useRef<number>(0);
|
||||||
|
|
||||||
@@ -1463,30 +1472,6 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [drawChart, running, paused]);
|
}, [drawChart, running, paused]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!running || paused) return;
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const activeItems = Object.values(items).filter((item) => item.status === "downloading");
|
|
||||||
if (activeItems.length === 0) return;
|
|
||||||
|
|
||||||
const totalSpeed = activeItems.reduce((sum, item) => sum + (item.speedBps || 0), 0);
|
|
||||||
|
|
||||||
const history = speedHistoryRef.current;
|
|
||||||
history.push({ time: now, speed: totalSpeed });
|
|
||||||
|
|
||||||
const cutoff = now - 60000;
|
|
||||||
let trimIndex = 0;
|
|
||||||
while (trimIndex < history.length && history[trimIndex].time < cutoff) {
|
|
||||||
trimIndex += 1;
|
|
||||||
}
|
|
||||||
if (trimIndex > 0) {
|
|
||||||
speedHistoryRef.current = history.slice(trimIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastUpdateRef.current = now;
|
|
||||||
}, [items, paused, running]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
|
||||||
@@ -1504,7 +1489,7 @@ const BandwidthChart = memo(function BandwidthChart({ items, running, paused, sp
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
drawChart();
|
drawChart();
|
||||||
}, [drawChart, items, paused]);
|
}, [drawChart, paused]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="bandwidth-chart-container">
|
<div ref={containerRef} className="bandwidth-chart-container">
|
||||||
@@ -4946,6 +4931,10 @@ export function App(): ReactElement {
|
|||||||
|
|
||||||
const downloadPackageSpeeds = useMemo(() => Object.fromEntries(packageSpeedMap), [packageSpeedMap]);
|
const downloadPackageSpeeds = useMemo(() => Object.fromEntries(packageSpeedMap), [packageSpeedMap]);
|
||||||
const liveDownloadSpeedBps = useMemo(() => getDownloadSpeedBps(snapshot.packageSpeedBps), [snapshot.packageSpeedBps]);
|
const liveDownloadSpeedBps = useMemo(() => getDownloadSpeedBps(snapshot.packageSpeedBps), [snapshot.packageSpeedBps]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!snapshot.session.running || snapshot.session.paused) return;
|
||||||
|
speedHistoryRef.current = appendBandwidthSample(speedHistoryRef.current, liveDownloadSpeedBps);
|
||||||
|
}, [liveDownloadSpeedBps, snapshot.packageSpeedBps, snapshot.session.paused, snapshot.session.running]);
|
||||||
const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
|
const downloadQueueTotalBytes = useMemo(() => getDownloadQueueTotalBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
|
||||||
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
|
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
|
||||||
...downloadsViewCore,
|
...downloadsViewCore,
|
||||||
@@ -4971,7 +4960,7 @@ export function App(): ReactElement {
|
|||||||
sortDirection: downloadsSortDescending ? "desc" : "asc",
|
sortDirection: downloadsSortDescending ? "desc" : "asc",
|
||||||
status: {
|
status: {
|
||||||
packages: snapshot.stats.totalPackages,
|
packages: snapshot.stats.totalPackages,
|
||||||
links: Object.keys(snapshot.session.items).length,
|
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)),
|
||||||
session: humanSize(snapshot.stats.totalDownloaded),
|
session: humanSize(snapshot.stats.totalDownloaded),
|
||||||
sessionBytes: snapshot.stats.totalDownloaded,
|
sessionBytes: snapshot.stats.totalDownloaded,
|
||||||
total: humanSize(downloadQueueTotalBytes),
|
total: humanSize(downloadQueueTotalBytes),
|
||||||
@@ -5963,7 +5952,7 @@ export function App(): ReactElement {
|
|||||||
{tab === "statistics" && (
|
{tab === "statistics" && (
|
||||||
<StatisticsContent
|
<StatisticsContent
|
||||||
actions={statisticsActions}
|
actions={statisticsActions}
|
||||||
chart={<BandwidthChart items={snapshot.session.items} running={snapshot.session.running} paused={snapshot.session.paused} speedHistoryRef={speedHistoryRef} />}
|
chart={<BandwidthChart running={snapshot.session.running} paused={snapshot.session.paused} speedHistoryRef={speedHistoryRef} />}
|
||||||
model={statisticsViewModel}
|
model={statisticsViewModel}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -30,7 +30,11 @@ export function compactProviderLabels(labels: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeDownloadServiceLabel(label: string): string {
|
export function normalizeDownloadServiceLabel(label: string): string {
|
||||||
return label.replace(/\s+(Web|API)\s+\(\1 Account\)$/i, " $1");
|
return [...new Set(label.split(",").map((entry) => entry.trim().replace(/\s+(Web|API)\s+\(\1 Account\)$/i, " $1")).filter(Boolean))].join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compactDownloadServiceLabel(label: string): string {
|
||||||
|
return [...new Set(normalizeDownloadServiceLabel(label).split(",").map((entry) => entry.trim().replace(/\s+(Web|API)$/i, "")).filter(Boolean))].join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDateTime(timestamp: number): string {
|
export function formatDateTime(timestamp: number): string {
|
||||||
|
|||||||
@@ -601,7 +601,7 @@
|
|||||||
.md-application-menu-tree :where(.menu-dropdown, .menu-submenu-dropdown) {
|
.md-application-menu-tree :where(.menu-dropdown, .menu-submenu-dropdown) {
|
||||||
z-index: var(--md-layer-menu);
|
z-index: var(--md-layer-menu);
|
||||||
max-height: calc(100vh - 16px);
|
max-height: calc(100vh - 16px);
|
||||||
overflow-y: auto;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-application-menu-tree > .menu-bar-item > .menu-dropdown {
|
.md-application-menu-tree > .menu-bar-item > .menu-dropdown {
|
||||||
@@ -609,6 +609,11 @@
|
|||||||
left: auto;
|
left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.md-application-menu-tree .menu-submenu-dropdown {
|
||||||
|
right: 100%;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.md-overlay-host {
|
.md-overlay-host {
|
||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { memo, useEffect, useLayoutEffect, useRef, useState, type CSSProperties,
|
|||||||
import type { DownloadItem } from "../../../shared/types";
|
import type { DownloadItem } from "../../../shared/types";
|
||||||
import {
|
import {
|
||||||
compactProviderLabels,
|
compactProviderLabels,
|
||||||
|
compactDownloadServiceLabel,
|
||||||
extractHoster,
|
extractHoster,
|
||||||
formatAudioStripSummary,
|
formatAudioStripSummary,
|
||||||
formatDateTime,
|
formatDateTime,
|
||||||
@@ -147,6 +148,17 @@ function DownloadStatusCell({ status, title }: { status: string; title?: string
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DownloadServiceCell({ label }: { label: string }): ReactElement {
|
||||||
|
const full = normalizeDownloadServiceLabel(label);
|
||||||
|
const compact = compactDownloadServiceLabel(label);
|
||||||
|
return (
|
||||||
|
<span aria-label={full} className="downloads-cell downloads-service-cell" title={label}>
|
||||||
|
<span aria-hidden="true" className="downloads-service-full">{full}</span>
|
||||||
|
<span aria-hidden="true" className="downloads-service-compact">{compact}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function itemCell(item: DownloadItem, column: string, sessionRunning: boolean): ReactElement | null {
|
function itemCell(item: DownloadItem, column: string, sessionRunning: boolean): ReactElement | null {
|
||||||
const displayStatus = displayedStatus(item, sessionRunning);
|
const displayStatus = displayedStatus(item, sessionRunning);
|
||||||
const retrySuffix = item.retries > 0 ? ` (R${item.retries})` : "";
|
const retrySuffix = item.retries > 0 ? ` (R${item.retries})` : "";
|
||||||
@@ -174,7 +186,7 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean):
|
|||||||
}
|
}
|
||||||
if (column === "account") {
|
if (column === "account") {
|
||||||
const full = item.providerLabel || (item.provider ? providerLabels[item.provider] : "");
|
const full = item.providerLabel || (item.provider ? providerLabels[item.provider] : "");
|
||||||
return <span className="downloads-cell" title={full}>{normalizeDownloadServiceLabel(full)}</span>;
|
return <DownloadServiceCell label={full} />;
|
||||||
}
|
}
|
||||||
if (column === "prio") return <span className="downloads-cell" />;
|
if (column === "prio") return <span className="downloads-cell" />;
|
||||||
if (column === "status") return <DownloadStatusCell status={displayStatus} title={statusTitle} />;
|
if (column === "status") return <DownloadStatusCell status={displayStatus} title={statusTitle} />;
|
||||||
@@ -393,7 +405,7 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
|||||||
}
|
}
|
||||||
if (column === "account") {
|
if (column === "account") {
|
||||||
const full = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
|
const full = compactProviderLabels(row.items.map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean));
|
||||||
return <span className="downloads-cell" title={full}>{normalizeDownloadServiceLabel(full)}</span>;
|
return <DownloadServiceCell label={full} />;
|
||||||
}
|
}
|
||||||
if (column === "prio") return <span className="downloads-cell">{entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}</span>;
|
if (column === "prio") return <span className="downloads-cell">{entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}</span>;
|
||||||
if (column === "status") {
|
if (column === "status") {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
} from "./DownloadsTable";
|
} from "./DownloadsTable";
|
||||||
import "./downloads.css";
|
import "./downloads.css";
|
||||||
|
|
||||||
|
const integerFormatter = new Intl.NumberFormat("de-DE", { maximumFractionDigits: 0 });
|
||||||
|
|
||||||
export interface DownloadsStatusModel {
|
export interface DownloadsStatusModel {
|
||||||
packages: number;
|
packages: number;
|
||||||
links: number;
|
links: number;
|
||||||
@@ -104,11 +106,11 @@ export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel })
|
|||||||
const speed = model.status.speed.replace(/^Geschwindigkeit:\s*/i, "");
|
const speed = model.status.speed.replace(/^Geschwindigkeit:\s*/i, "");
|
||||||
const eta = model.status.eta.replace(/^ETA:\s*/i, "");
|
const eta = model.status.eta.replace(/^ETA:\s*/i, "");
|
||||||
const entries = [
|
const entries = [
|
||||||
{ label: "Pakete", metric: "packages", numericValue: model.status.packages, value: String(model.status.packages) },
|
{ label: "Pakete", metric: "packages", numericValue: model.status.packages, value: integerFormatter.format(model.status.packages) },
|
||||||
{ label: "Links", metric: "links", numericValue: model.status.links, value: String(model.status.links) },
|
{ label: "Links", metric: "links", numericValue: model.status.links, value: integerFormatter.format(model.status.links) },
|
||||||
{ label: "Sitzung", metric: "session", numericValue: model.status.sessionBytes, value: model.status.session },
|
{ label: "Sitzung", metric: "session", numericValue: model.status.sessionBytes, value: model.status.session },
|
||||||
{ label: "Gesamt", metric: "total", numericValue: model.status.totalBytes, value: model.status.total },
|
{ label: "Gesamt", metric: "total", numericValue: model.status.totalBytes, value: model.status.total },
|
||||||
{ label: "Hoster", metric: "hosters", numericValue: model.status.hosters, value: String(model.status.hosters) }
|
{ label: "Hoster", metric: "hosters", numericValue: model.status.hosters, value: integerFormatter.format(model.status.hosters) }
|
||||||
];
|
];
|
||||||
return <section className="downloads-sidebar-status" data-visual-region="downloads-sidebar-status" aria-label="Downloadstatus">{entries.map((entry) => <div key={entry.metric}><span>{entry.label}</span><RollingMetricValue numericValue={entry.numericValue} value={entry.value} /></div>)}<div><span>Geschwindigkeit</span><strong data-status-metric="speed">{speed}</strong></div><div><span>ETA</span><strong data-status-metric="eta">{eta}</strong></div></section>;
|
return <section className="downloads-sidebar-status" data-visual-region="downloads-sidebar-status" aria-label="Downloadstatus">{entries.map((entry) => <div key={entry.metric}><span>{entry.label}</span><RollingMetricValue numericValue={entry.numericValue} value={entry.value} /></div>)}<div><span>Geschwindigkeit</span><strong data-status-metric="speed">{speed}</strong></div><div><span>ETA</span><strong data-status-metric="eta">{eta}</strong></div></section>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): numbe
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getPendingDownloadItemCount(items: Iterable<DownloadItem>): number {
|
||||||
|
let count = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.status !== "completed" && item.status !== "cancelled" && item.status !== "failed") count += 1;
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
export function getDownloadSpeedBps(packageSpeeds: Record<string, number>): number {
|
export function getDownloadSpeedBps(packageSpeeds: Record<string, number>): number {
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (const speed of Object.values(packageSpeeds)) {
|
for (const speed of Object.values(packageSpeeds)) {
|
||||||
|
|||||||
@@ -545,7 +545,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.downloads-link-state.online {
|
.downloads-link-state.online {
|
||||||
background: var(--ui-primary);
|
background: var(--ui-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-link-state.offline {
|
.downloads-link-state.offline {
|
||||||
@@ -637,10 +637,11 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-meter-label.is-track {
|
.downloads-meter-label.is-track {
|
||||||
|
clip-path: inset(0 0 0 var(--downloads-progress));
|
||||||
color: var(--ui-progress-track-text, #fff);
|
color: var(--ui-progress-track-text, #fff);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,7 +657,16 @@
|
|||||||
color: var(--ui-progress-fill-text, #171a1f);
|
color: var(--ui-progress-fill-text, #171a1f);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-status-compact {
|
.downloads-status-cell {
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-service-cell {
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-status-compact,
|
||||||
|
.downloads-service-compact {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,16 +675,24 @@
|
|||||||
--downloads-status-min: 90px;
|
--downloads-status-min: 90px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-shell.is-compact .downloads-status-full,
|
@container (max-width: 150px) {
|
||||||
.md-shell.is-minimum .downloads-status-full {
|
.downloads-status-full {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-shell.is-compact .downloads-status-compact,
|
.downloads-status-compact {
|
||||||
.md-shell.is-minimum .downloads-status-compact {
|
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.downloads-service-full {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloads-service-compact {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.downloads-empty-state,
|
.downloads-empty-state,
|
||||||
.downloads-table-message {
|
.downloads-table-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -296,8 +296,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-switch.is-on {
|
.settings-switch.is-on {
|
||||||
border-color: var(--ui-accent);
|
border-color: var(--ui-success);
|
||||||
background: var(--ui-accent);
|
background: var(--ui-success);
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-switch.is-on > span {
|
.settings-switch.is-on > span {
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ describe("desktop shell", () => {
|
|||||||
expect(shellCss).toMatch(/\.md-application-menu-tree > \.menu-bar-item > \.menu-dropdown\s*\{[^}]*right:\s*0;[^}]*left:\s*auto;/s);
|
expect(shellCss).toMatch(/\.md-application-menu-tree > \.menu-bar-item > \.menu-dropdown\s*\{[^}]*right:\s*0;[^}]*left:\s*auto;/s);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps application submenus visible inside narrow right-aligned windows", () => {
|
||||||
|
const shellCss = readFileSync(new URL("../src/renderer/shell/shell.css", import.meta.url), "utf8");
|
||||||
|
|
||||||
|
expect(shellCss).toMatch(/\.md-application-menu-tree :where\(\.menu-dropdown, \.menu-submenu-dropdown\)\s*\{[^}]*overflow:\s*visible;/s);
|
||||||
|
expect(shellCss).toMatch(/\.md-application-menu-tree \.menu-submenu-dropdown\s*\{[^}]*right:\s*100%;[^}]*left:\s*auto;/s);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses the product asset in the header brand", () => {
|
it("uses the product asset in the header brand", () => {
|
||||||
const html = renderToStaticMarkup(<AppHeader activeView="downloads" actions={null} onViewChange={() => {}} />);
|
const html = renderToStaticMarkup(<AppHeader activeView="downloads" actions={null} onViewChange={() => {}} />);
|
||||||
expect(html).toContain("Multi-Debrid Downloader");
|
expect(html).toContain("Multi-Debrid Downloader");
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
buildDownloadsViewModel,
|
buildDownloadsViewModel,
|
||||||
classifyDownloadStatus,
|
classifyDownloadStatus,
|
||||||
getDownloadQueueTotalBytes,
|
getDownloadQueueTotalBytes,
|
||||||
|
getPendingDownloadItemCount,
|
||||||
getDownloadSpeedBps,
|
getDownloadSpeedBps,
|
||||||
type DownloadSidebarFilter,
|
type DownloadSidebarFilter,
|
||||||
type DownloadsModelInput
|
type DownloadsModelInput
|
||||||
@@ -33,7 +34,7 @@ import {
|
|||||||
downloadColumnDefinitions,
|
downloadColumnDefinitions,
|
||||||
getAvailabilitySummary
|
getAvailabilitySummary
|
||||||
} from "../src/renderer/views/downloads/DownloadsTable";
|
} from "../src/renderer/views/downloads/DownloadsTable";
|
||||||
import { normalizeDownloadServiceLabel } from "../src/renderer/download-format";
|
import { compactDownloadServiceLabel, normalizeDownloadServiceLabel } from "../src/renderer/download-format";
|
||||||
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
|
import { getRollingMetricDirection } from "../src/renderer/ui/RollingMetricValue";
|
||||||
|
|
||||||
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
||||||
@@ -125,6 +126,28 @@ describe("Download-Gesamtgröße", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("laufender Queue-Linkzähler", () => {
|
||||||
|
it("sinkt sofort, sobald eine Unterdatei abgeschlossen ist", () => {
|
||||||
|
const items = [
|
||||||
|
item("queued", "package-a", "queued"),
|
||||||
|
item("active", "package-a", "downloading"),
|
||||||
|
item("done", "package-a", "completed"),
|
||||||
|
item("failed", "package-a", "failed")
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getPendingDownloadItemCount(items)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formatiert große Sidebar-Zähler mit deutschen Tausenderpunkten", () => {
|
||||||
|
const model = withRuntime(createInput(), {
|
||||||
|
status: { ...withRuntime(createInput()).status, packages: 130, links: 2484 }
|
||||||
|
});
|
||||||
|
const html = renderToStaticMarkup(<DownloadsSidebarStatus model={model} />);
|
||||||
|
|
||||||
|
expect(html).toContain(">2.484<");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("responsive Downloadstatus und Servicebezeichnungen", () => {
|
describe("responsive Downloadstatus und Servicebezeichnungen", () => {
|
||||||
it("keeps full status details while providing compact table text", () => {
|
it("keeps full status details while providing compact table text", () => {
|
||||||
expect(compactDownloadStatus("Link wird umgewandelt")).toBe("Umwandeln");
|
expect(compactDownloadStatus("Link wird umgewandelt")).toBe("Umwandeln");
|
||||||
@@ -137,6 +160,8 @@ describe("responsive Downloadstatus und Servicebezeichnungen", () => {
|
|||||||
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid Web");
|
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account)")).toBe("Mega-Debrid Web");
|
||||||
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid API");
|
expect(normalizeDownloadServiceLabel("Mega-Debrid API (API Account)")).toBe("Mega-Debrid API");
|
||||||
expect(normalizeDownloadServiceLabel("Real-Debrid (Web Account)")).toBe("Real-Debrid (Web Account)");
|
expect(normalizeDownloadServiceLabel("Real-Debrid (Web Account)")).toBe("Real-Debrid (Web Account)");
|
||||||
|
expect(normalizeDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid Web, Mega-Debrid API");
|
||||||
|
expect(compactDownloadServiceLabel("Mega-Debrid Web (Web Account), Mega-Debrid API (API Account)")).toBe("Mega-Debrid");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -622,7 +647,14 @@ describe("downloads view", () => {
|
|||||||
expect(css).toMatch(/\.downloads-package-items\s*\{[^}]*height:\s*auto;[^}]*overflow:\s*hidden;/s);
|
expect(css).toMatch(/\.downloads-package-items\s*\{[^}]*height:\s*auto;[^}]*overflow:\s*hidden;/s);
|
||||||
expect(css).toMatch(/\.downloads-package-items\.is-collapsed\s*\{[^}]*height:\s*0;[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;/s);
|
expect(css).toMatch(/\.downloads-package-items\.is-collapsed\s*\{[^}]*height:\s*0;[^}]*opacity:\s*0;[^}]*pointer-events:\s*none;/s);
|
||||||
expect(css).toMatch(/\.downloads-meter-label\.is-track\s*\{[^}]*color:\s*var\(--ui-progress-track-text/s);
|
expect(css).toMatch(/\.downloads-meter-label\.is-track\s*\{[^}]*color:\s*var\(--ui-progress-track-text/s);
|
||||||
|
expect(css).toMatch(/\.downloads-meter-label\s*\{[^}]*font-weight:\s*700;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-meter-label\.is-track\s*\{[^}]*clip-path:\s*inset\(0 0 0 var\(--downloads-progress\)\);/s);
|
||||||
expect(css).toMatch(/\.downloads-meter-label\.is-filled\s*\{[^}]*color:\s*var\(--ui-progress-fill-text/s);
|
expect(css).toMatch(/\.downloads-meter-label\.is-filled\s*\{[^}]*color:\s*var\(--ui-progress-fill-text/s);
|
||||||
|
expect(css).toMatch(/\.downloads-link-state\.online\s*\{[^}]*background:\s*var\(--ui-success\);/s);
|
||||||
|
expect(css).toMatch(/\.downloads-status-cell\s*\{[^}]*container-type:\s*inline-size;/s);
|
||||||
|
expect(css).toMatch(/\.downloads-service-cell\s*\{[^}]*container-type:\s*inline-size;/s);
|
||||||
|
expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-status-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-status-compact[^{]*\{[^}]*display:\s*inline;/s);
|
||||||
|
expect(css).toMatch(/@container\s*\(max-width:\s*150px\)[\s\S]*\.downloads-service-full[^{]*\{[^}]*display:\s*none;[\s\S]*\.downloads-service-compact[^{]*\{[^}]*display:\s*inline;/s);
|
||||||
expect(readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8")).toMatch(/\.animate\(\[\{ height: "0px", opacity: 0 \}, \{ height: `\$\{targetHeight\}px`, opacity: 1 \}\]/);
|
expect(readFileSync(new URL("../src/renderer/views/downloads/DownloadsTable.tsx", import.meta.url), "utf8")).toMatch(/\.animate\(\[\{ height: "0px", opacity: 0 \}, \{ height: `\$\{targetHeight\}px`, opacity: 1 \}\]/);
|
||||||
expect(css).toMatch(/\.downloads-footer\s*\{[^}]*height:\s*60px;[^}]*padding:\s*0 12px 0 60px;/s);
|
expect(css).toMatch(/\.downloads-footer\s*\{[^}]*height:\s*60px;[^}]*padding:\s*0 12px 0 60px;/s);
|
||||||
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid color-mix\(in srgb, var\(--ui-border\) 72%, transparent\);[^}]*padding:\s*0;/s);
|
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*border:\s*0;[^}]*border-bottom:\s*1px solid color-mix\(in srgb, var\(--ui-border\) 72%, transparent\);[^}]*padding:\s*0;/s);
|
||||||
@@ -800,7 +832,8 @@ describe("download table row contracts", () => {
|
|||||||
expect(html).toContain('class="downloads-status-compact"');
|
expect(html).toContain('class="downloads-status-compact"');
|
||||||
expect(html).toContain("DL läuft");
|
expect(html).toContain("DL läuft");
|
||||||
expect(html).toContain('title="Mega-Debrid Web (Web Account)"');
|
expect(html).toContain('title="Mega-Debrid Web (Web Account)"');
|
||||||
expect(html).toContain(">Mega-Debrid Web</span>");
|
expect(html).toContain('class="downloads-service-full">Mega-Debrid Web</span>');
|
||||||
|
expect(html).toContain('class="downloads-service-compact">Mega-Debrid</span>');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sets the whole visible selection atomically from the header checkbox", () => {
|
it("sets the whole visible selection atomically from the header checkbox", () => {
|
||||||
|
|||||||
@@ -697,6 +697,7 @@ describe("settings geometry", () => {
|
|||||||
expect(css).toMatch(/\.settings-form-column\s*{[^}]*width:\s*500px;[^}]*max-width:\s*100%;/s);
|
expect(css).toMatch(/\.settings-form-column\s*{[^}]*width:\s*500px;[^}]*max-width:\s*100%;/s);
|
||||||
expect(css).toMatch(/\.settings-control\s*{[^}]*height:\s*44px;[^}]*border-radius:\s*6px;/s);
|
expect(css).toMatch(/\.settings-control\s*{[^}]*height:\s*44px;[^}]*border-radius:\s*6px;/s);
|
||||||
expect(css).toMatch(/\.settings-switch\s*{[^}]*width:\s*40px;[^}]*height:\s*20px;/s);
|
expect(css).toMatch(/\.settings-switch\s*{[^}]*width:\s*40px;[^}]*height:\s*20px;/s);
|
||||||
|
expect(css).toMatch(/\.settings-switch\.is-on\s*{[^}]*border-color:\s*var\(--ui-success\);[^}]*background:\s*var\(--ui-success\);/s);
|
||||||
expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*height:\s*41px;/s);
|
expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*height:\s*41px;/s);
|
||||||
expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*overflow:\s*hidden;/s);
|
expect(css).toMatch(/\.settings-account-table-header\s*{[^}]*overflow:\s*hidden;/s);
|
||||||
expect(css).toMatch(/\.settings-account-table-grid\s*{[^}]*color:\s*var\(--ui-text\);/s);
|
expect(css).toMatch(/\.settings-account-table-grid\s*{[^}]*color:\s*var\(--ui-text\);/s);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { isValidElement, type ReactElement, type ReactNode } from "react";
|
|||||||
import { renderToStaticMarkup } from "react-dom/server";
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types";
|
import type { DownloadItem, DownloadStatus, UiSnapshot } from "../src/shared/types";
|
||||||
import { readBandwidthChartPalette } from "../src/renderer/App";
|
import { appendBandwidthSample, readBandwidthChartPalette } from "../src/renderer/App";
|
||||||
import {
|
import {
|
||||||
buildStatisticsViewModel,
|
buildStatisticsViewModel,
|
||||||
type StatisticsMetric,
|
type StatisticsMetric,
|
||||||
@@ -19,6 +19,21 @@ import { createVisualFixture } from "./visual/fixtures";
|
|||||||
|
|
||||||
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
const now = new Date(2026, 7, 10, 12, 0, 0, 0).getTime();
|
||||||
|
|
||||||
|
describe("bandwidth sampling", () => {
|
||||||
|
it("keeps the latest minute and normalizes invalid speeds", () => {
|
||||||
|
const history = [
|
||||||
|
{ time: now - 61000, speed: 1 },
|
||||||
|
{ time: now - 59000, speed: 2 }
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(appendBandwidthSample(history, Number.NaN, now)).toEqual([
|
||||||
|
{ time: now - 59000, speed: 2 },
|
||||||
|
{ time: now, speed: 0 }
|
||||||
|
]);
|
||||||
|
expect(history).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function createSnapshot(): UiSnapshot {
|
function createSnapshot(): UiSnapshot {
|
||||||
return structuredClone(createVisualFixture("empty").snapshot);
|
return structuredClone(createVisualFixture("empty").snapshot);
|
||||||
}
|
}
|
||||||
@@ -385,6 +400,15 @@ describe("statistics view", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("bandwidth chart palette", () => {
|
describe("bandwidth chart palette", () => {
|
||||||
|
it("collects bandwidth samples from the mounted application instead of the statistics tab", () => {
|
||||||
|
const source = readFileSync(new URL("../src/renderer/App.tsx", import.meta.url), "utf8");
|
||||||
|
const chartBlock = source.slice(source.indexOf("const BandwidthChart"), source.indexOf("interface DownloadSpeedSparklineProps"));
|
||||||
|
|
||||||
|
expect(source).toContain("appendBandwidthSample(speedHistoryRef.current, liveDownloadSpeedBps");
|
||||||
|
expect(chartBlock).not.toContain("history.push");
|
||||||
|
expect(chartBlock).not.toContain('item.status === "downloading"');
|
||||||
|
});
|
||||||
|
|
||||||
it("defines semantic speed and progress text colors for both themes", () => {
|
it("defines semantic speed and progress text colors for both themes", () => {
|
||||||
const css = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
|
const css = readFileSync(new URL("../src/renderer/theme.css", import.meta.url), "utf8");
|
||||||
const dark = css.match(/:root,\s*:root\[data-theme="dark"\]\s*\{([\s\S]*?)\}/)?.[1];
|
const dark = css.match(/:root,\s*:root\[data-theme="dark"\]\s*\{([\s\S]*?)\}/)?.[1];
|
||||||
|
|||||||
Reference in New Issue
Block a user