feat(downloads): restore package disclosure motion and remaining size
Restore smooth 300 ms expand and collapse transitions without removing row virtualization. Limit animated disclosure rows globally, preserve live item updates during motion, support rapid reversal, and keep large queues bounded. Add a Remaining sidebar metric that sums only unfinished known bytes while clearly marking unknown open sizes. Cover multi-package limits, live transition merges, honest remaining-size semantics, translations, and accessibility.
This commit is contained in:
@@ -94,7 +94,7 @@ import {
|
|||||||
StatisticsSidebarStatus,
|
StatisticsSidebarStatus,
|
||||||
type StatisticsViewActions
|
type StatisticsViewActions
|
||||||
} from "./views/statistics/StatisticsView";
|
} from "./views/statistics/StatisticsView";
|
||||||
import { buildDownloadsViewModel, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, type DownloadDisplayMode, type DownloadSidebarFilter } from "./views/downloads/downloads-model";
|
import { buildDownloadsViewModel, formatRemainingDownloadBytes, getDownloadQueueTotalBytes, getDownloadSpeedBps, getPendingDownloadItemCount, getRemainingDownloadBytes, 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 {
|
||||||
@@ -1562,6 +1562,7 @@ export function App(): ReactElement {
|
|||||||
const pendingPackageOrderRef = useRef<string[] | null>(null);
|
const pendingPackageOrderRef = useRef<string[] | null>(null);
|
||||||
const pendingPackageOrderAtRef = useRef(0);
|
const pendingPackageOrderAtRef = useRef(0);
|
||||||
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
||||||
|
const [downloadDisclosureRevision, setDownloadDisclosureRevision] = useState(0);
|
||||||
const [downloadSearch, setDownloadSearch] = useState("");
|
const [downloadSearch, setDownloadSearch] = useState("");
|
||||||
const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages");
|
const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages");
|
||||||
const [downloadFilter, setDownloadFilter] = useState<DownloadSidebarFilter>("all");
|
const [downloadFilter, setDownloadFilter] = useState<DownloadSidebarFilter>("all");
|
||||||
@@ -3673,6 +3674,7 @@ export function App(): ReactElement {
|
|||||||
}, [showToast]);
|
}, [showToast]);
|
||||||
|
|
||||||
const onPackageToggleCollapse = useCallback((packageId: string): void => {
|
const onPackageToggleCollapse = useCallback((packageId: string): void => {
|
||||||
|
setDownloadDisclosureRevision((current) => current + 1);
|
||||||
setCollapsedPackages((prev) => {
|
setCollapsedPackages((prev) => {
|
||||||
const nextCollapsed = !(prev[packageId] ?? false);
|
const nextCollapsed = !(prev[packageId] ?? false);
|
||||||
return { ...prev, [packageId]: nextCollapsed };
|
return { ...prev, [packageId]: nextCollapsed };
|
||||||
@@ -4571,6 +4573,7 @@ export function App(): ReactElement {
|
|||||||
speedHistoryRef.current = appendBandwidthSample(speedHistoryRef.current, liveDownloadSpeedBps);
|
speedHistoryRef.current = appendBandwidthSample(speedHistoryRef.current, liveDownloadSpeedBps);
|
||||||
}, [liveDownloadSpeedBps, snapshot.packageSpeedBps, snapshot.session.paused, snapshot.session.running]);
|
}, [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 downloadRemaining = useMemo(() => getRemainingDownloadBytes(Object.values(snapshot.session.items)), [snapshot.session.items]);
|
||||||
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
|
const downloadsViewModel = useMemo<DownloadsViewModel>(() => ({
|
||||||
...downloadsViewCore,
|
...downloadsViewCore,
|
||||||
running: snapshot.session.running,
|
running: snapshot.session.running,
|
||||||
@@ -4593,6 +4596,7 @@ export function App(): ReactElement {
|
|||||||
gridTemplate,
|
gridTemplate,
|
||||||
sortColumn: downloadsSortColumn,
|
sortColumn: downloadsSortColumn,
|
||||||
sortDirection: downloadsSortDescending ? "desc" : "asc",
|
sortDirection: downloadsSortDescending ? "desc" : "asc",
|
||||||
|
disclosureRevision: downloadDisclosureRevision,
|
||||||
status: {
|
status: {
|
||||||
packages: snapshot.stats.totalPackages,
|
packages: snapshot.stats.totalPackages,
|
||||||
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)),
|
links: getPendingDownloadItemCount(Object.values(snapshot.session.items)),
|
||||||
@@ -4600,11 +4604,13 @@ export function App(): ReactElement {
|
|||||||
sessionBytes: snapshot.stats.totalDownloaded,
|
sessionBytes: snapshot.stats.totalDownloaded,
|
||||||
total: humanSize(downloadQueueTotalBytes),
|
total: humanSize(downloadQueueTotalBytes),
|
||||||
totalBytes: downloadQueueTotalBytes,
|
totalBytes: downloadQueueTotalBytes,
|
||||||
|
remaining: formatRemainingDownloadBytes(downloadRemaining),
|
||||||
|
remainingBytes: downloadRemaining.bytes,
|
||||||
hosters: providerStats.length,
|
hosters: providerStats.length,
|
||||||
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
|
speed: liveDownloadSpeedBps > 0 ? formatSpeedMbps(liveDownloadSpeedBps) : "0 B/s",
|
||||||
eta: snapshot.etaText
|
eta: snapshot.etaText
|
||||||
}
|
}
|
||||||
}), [actionBusy, columnOrder, downloadPackageSpeeds, downloadQueueTotalBytes, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
|
}), [actionBusy, columnOrder, downloadDisclosureRevision, downloadPackageSpeeds, downloadQueueTotalBytes, downloadRemaining, downloadsSortColumn, downloadsSortDescending, downloadsViewCore, editingName, editingPackageId, gridTemplate, liveDownloadSpeedBps, providerStats.length, scheduleCountdown, schedulePickerOpen, scheduleTimeInput, snapshot.canPause, snapshot.canStart, snapshot.canStop, snapshot.clipboardActive, snapshot.etaText, snapshot.reconnectSeconds, snapshot.session.items, snapshot.session.paused, snapshot.session.reconnectReason, snapshot.session.running, snapshot.settings.scheduledStartEpochMs, snapshot.stats.totalDownloaded, snapshot.stats.totalPackages]);
|
||||||
|
|
||||||
const downloadsActions: DownloadsViewActions = {
|
const downloadsActions: DownloadsViewActions = {
|
||||||
onDisplayModeChange: setDownloadDisplayMode,
|
onDisplayModeChange: setDownloadDisplayMode,
|
||||||
@@ -4647,6 +4653,7 @@ export function App(): ReactElement {
|
|||||||
onClearAll: clearDownloadQueue,
|
onClearAll: clearDownloadQueue,
|
||||||
onToggleAllPackages: () => {
|
onToggleAllPackages: () => {
|
||||||
const targetState = !allPackagesCollapsed;
|
const targetState = !allPackagesCollapsed;
|
||||||
|
setDownloadDisclosureRevision((current) => current + 1);
|
||||||
setCollapsedPackages((current) => {
|
setCollapsedPackages((current) => {
|
||||||
const next = { ...current };
|
const next = { ...current };
|
||||||
for (const entry of packages) {
|
for (const entry of packages) {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ const pairs = [
|
|||||||
["Umbenennen", "Rename"], ["Entfernen", "Remove"], ["Name", "Name"], ["Geladen / Größe", "Downloaded / size"], ["Fortschritt", "Progress"], ["Hoster", "Hoster"], ["Service", "Service"],
|
["Umbenennen", "Rename"], ["Entfernen", "Remove"], ["Name", "Name"], ["Geladen / Größe", "Downloaded / size"], ["Fortschritt", "Progress"], ["Hoster", "Hoster"], ["Service", "Service"],
|
||||||
["Priorität", "Priority"], ["Status", "Status"], ["Aktion", "Action"], ["Alle Services", "All services"], ["Paket, Datei oder Service", "Package, file or service"], ["Alle ein-/ausklappen", "Expand/collapse all"],
|
["Priorität", "Priority"], ["Status", "Status"], ["Aktion", "Action"], ["Alle Services", "All services"], ["Paket, Datei oder Service", "Package, file or service"], ["Alle ein-/ausklappen", "Expand/collapse all"],
|
||||||
["Hoch", "High"], ["Normal", "Normal"], ["Niedrig", "Low"], ["In Warteschlange", "Queued"], ["Abgeschlossen", "Completed"], ["Entpackt", "Extracted"], ["Automatisch entpacken", "Extract automatically"],
|
["Hoch", "High"], ["Normal", "Normal"], ["Niedrig", "Low"], ["In Warteschlange", "Queued"], ["Abgeschlossen", "Completed"], ["Entpackt", "Extracted"], ["Automatisch entpacken", "Extract automatically"],
|
||||||
["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["Wartet", "Waiting"], ["Offline", "Offline"],
|
["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Verbleibend", "Remaining"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["Wartet", "Waiting"], ["Offline", "Offline"],
|
||||||
["Übersicht", "Overview"], ["Verwendungsregeln", "Usage rules"], ["Accountverwaltung", "Account management"], ["Accounts hinzufügen, prüfen und verwalten.", "Add, check and manage accounts."],
|
["Übersicht", "Overview"], ["Verwendungsregeln", "Usage rules"], ["Accountverwaltung", "Account management"], ["Accounts hinzufügen, prüfen und verwalten.", "Add, check and manage accounts."],
|
||||||
["Accounts zum Herunterladen verwenden", "Use accounts for downloads"], ["Download-Traffic übrig", "Download traffic remaining"], ["Benutzername", "Username"], ["E-Mail", "Email"], ["Verfallsdatum", "Expiration date"], ["Passwort/Zugang", "Password/access"],
|
["Accounts zum Herunterladen verwenden", "Use accounts for downloads"], ["Download-Traffic übrig", "Download traffic remaining"], ["Benutzername", "Username"], ["E-Mail", "Email"], ["Verfallsdatum", "Expiration date"], ["Passwort/Zugang", "Password/access"],
|
||||||
["Account hinzufügen", "Add account"], ["Ausgewählte prüfen", "Check selected"], ["Ausgewählte entfernen", "Remove selected"], ["Aktivieren", "Enable"], ["Deaktivieren", "Disable"], ["Noch nicht geprüft", "Not checked yet"],
|
["Account hinzufügen", "Add account"], ["Ausgewählte prüfen", "Check selected"], ["Ausgewählte entfernen", "Remove selected"], ["Aktivieren", "Enable"], ["Deaktivieren", "Disable"], ["Noch nicht geprüft", "Not checked yet"],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useEffect, useLayoutEffect, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement } from "react";
|
import { memo, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactElement } from "react";
|
||||||
import type { DownloadItem } from "../../../shared/types";
|
import type { DownloadItem } from "../../../shared/types";
|
||||||
import {
|
import {
|
||||||
compactProviderLabels,
|
compactProviderLabels,
|
||||||
@@ -19,7 +19,6 @@ export type DownloadSortColumn = "name" | "size" | "hoster" | "progress";
|
|||||||
const DOWNLOAD_SELECTION_COLUMN_WIDTH = "36px";
|
const DOWNLOAD_SELECTION_COLUMN_WIDTH = "36px";
|
||||||
const DOWNLOAD_ACTION_COLUMN_WIDTH = "60px";
|
const DOWNLOAD_ACTION_COLUMN_WIDTH = "60px";
|
||||||
const PACKAGE_ROW_DISCLOSURE_EXCLUSION_SELECTOR = "button, input, select, textarea, a, [contenteditable='true'], .downloads-copyable, .downloads-meter";
|
const PACKAGE_ROW_DISCLOSURE_EXCLUSION_SELECTOR = "button, input, select, textarea, a, [contenteditable='true'], .downloads-copyable, .downloads-meter";
|
||||||
const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
|
||||||
|
|
||||||
type HosterLabel = ReturnType<typeof formatHosterLabel>;
|
type HosterLabel = ReturnType<typeof formatHosterLabel>;
|
||||||
|
|
||||||
@@ -314,73 +313,6 @@ export function areItemRowPropsEqual(previous: ItemRowProps, next: ItemRowProps)
|
|||||||
|
|
||||||
export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual);
|
export const ItemRow = memo(ItemRowContent, areItemRowPropsEqual);
|
||||||
|
|
||||||
interface PackageItemsTransitionProps {
|
|
||||||
actions: DownloadsTableActions;
|
|
||||||
collapsed: boolean;
|
|
||||||
columnOrder: readonly string[];
|
|
||||||
gridTemplate: string;
|
|
||||||
id: string;
|
|
||||||
items: DownloadItem[];
|
|
||||||
selectedIds: ReadonlySet<string>;
|
|
||||||
sessionRunning: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate, id, items, selectedIds, sessionRunning }: PackageItemsTransitionProps): ReactElement | null {
|
|
||||||
const [renderItems, setRenderItems] = useState(!collapsed);
|
|
||||||
const animationRef = useRef<Animation | null>(null);
|
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const innerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const initialRenderRef = useRef(true);
|
|
||||||
|
|
||||||
useRendererLayoutEffect(() => {
|
|
||||||
if (initialRenderRef.current) {
|
|
||||||
initialRenderRef.current = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!renderItems) {
|
|
||||||
if (!collapsed) setRenderItems(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const container = containerRef.current;
|
|
||||||
const inner = innerRef.current;
|
|
||||||
if (!container || !inner) return;
|
|
||||||
animationRef.current?.cancel();
|
|
||||||
const targetHeight = inner.scrollHeight;
|
|
||||||
const animation = collapsed
|
|
||||||
? container.animate([{ height: `${targetHeight}px`, opacity: 1 }, { height: "0px", opacity: 0 }], { duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" })
|
|
||||||
: container.animate([{ height: "0px", opacity: 0 }, { height: `${targetHeight}px`, opacity: 1 }], { duration: 300, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards" });
|
|
||||||
animationRef.current = animation;
|
|
||||||
animation.onfinish = () => {
|
|
||||||
if (animationRef.current !== animation) return;
|
|
||||||
animationRef.current = null;
|
|
||||||
if (collapsed) {
|
|
||||||
setRenderItems(false);
|
|
||||||
} else {
|
|
||||||
animation.cancel();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
return () => {
|
|
||||||
animation.onfinish = null;
|
|
||||||
animation.cancel();
|
|
||||||
if (animationRef.current === animation) animationRef.current = null;
|
|
||||||
};
|
|
||||||
}, [collapsed, renderItems]);
|
|
||||||
|
|
||||||
if (!renderItems) return null;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
aria-hidden={collapsed}
|
|
||||||
className={`downloads-package-items ${collapsed ? "is-collapsed" : "is-expanded"}`}
|
|
||||||
id={id}
|
|
||||||
ref={containerRef}
|
|
||||||
>
|
|
||||||
<div className="downloads-package-items-inner" ref={innerRef}>
|
|
||||||
{items.map((item) => <ItemRow actions={actions} columnOrder={columnOrder} gridTemplate={gridTemplate} item={item} key={item.id} selected={selectedIds.has(item.id)} sessionRunning={sessionRunning} />)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPackageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
|
export function getPackageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
|
||||||
let done = Math.max(0, Number(row.package.cleanedCompletedItemCount || 0));
|
let done = Math.max(0, Number(row.package.cleanedCompletedItemCount || 0));
|
||||||
let failed = 0;
|
let failed = 0;
|
||||||
@@ -429,7 +361,7 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
|||||||
if (column === "name") {
|
if (column === "name") {
|
||||||
return (
|
return (
|
||||||
<span className="downloads-cell downloads-name-cell">
|
<span className="downloads-cell downloads-name-cell">
|
||||||
<button aria-controls={`downloads-package-items-${entry.id}`} aria-expanded={!row.collapsed} aria-label={row.collapsed ? `${entry.name} ausklappen` : `${entry.name} einklappen`} className="downloads-collapse-button" onClick={(event) => { event.stopPropagation(); actions.onTogglePackageCollapse(entry.id); }} type="button">{row.collapsed ? "+" : "−"}</button>
|
<button aria-expanded={!row.collapsed} aria-label={row.collapsed ? `${entry.name} ausklappen` : `${entry.name} einklappen`} className="downloads-collapse-button" onClick={(event) => { event.stopPropagation(); actions.onTogglePackageCollapse(entry.id); }} type="button">{row.collapsed ? "+" : "−"}</button>
|
||||||
{editing
|
{editing
|
||||||
? <input autoFocus className="downloads-rename-input" value={editingName} onBlur={() => finishRename(editingName)} onChange={(event) => actions.onPackageRenameChange(event.target.value)} onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
|
? <input autoFocus className="downloads-rename-input" value={editingName} onBlur={() => finishRename(editingName)} onChange={(event) => actions.onPackageRenameChange(event.target.value)} onKeyDown={(event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||||
if (event.key === "Enter") {
|
if (event.key === "Enter") {
|
||||||
@@ -502,11 +434,10 @@ export interface PackageCardProps {
|
|||||||
sessionRunning?: boolean;
|
sessionRunning?: boolean;
|
||||||
columnOrder: readonly string[];
|
columnOrder: readonly string[];
|
||||||
gridTemplate: string;
|
gridTemplate: string;
|
||||||
renderItems?: boolean;
|
|
||||||
actions: DownloadsTableActions;
|
actions: DownloadsTableActions;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PackageCardContent({ row, selectedIds, editing, editingName, packageSpeedBps, sessionRunning = true, columnOrder, gridTemplate, renderItems = true, actions }: PackageCardProps): ReactElement {
|
export function PackageCardContent({ row, selectedIds, editing, editingName, packageSpeedBps, sessionRunning = true, columnOrder, gridTemplate, actions }: PackageCardProps): ReactElement {
|
||||||
const entry = row.package;
|
const entry = row.package;
|
||||||
let renameFinished = false;
|
let renameFinished = false;
|
||||||
const finishRename = (value: string): void => {
|
const finishRename = (value: string): void => {
|
||||||
@@ -547,7 +478,6 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
|
|||||||
{columnOrder.map((column) => <span className="downloads-cell-slot" data-download-column={column} key={column} role="cell">{packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)}</span>)}
|
{columnOrder.map((column) => <span className="downloads-cell-slot" data-download-column={column} key={column} role="cell">{packageCell(row, column, packageSpeedBps, editing, editingName, actions, finishRename)}</span>)}
|
||||||
<span className="downloads-action-cell" role="cell"><button aria-label={`${entry.name} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id); }} type="button">⋮</button></span>
|
<span className="downloads-action-cell" role="cell"><button aria-label={`${entry.name} Aktionen`} onClick={(event) => { event.stopPropagation(); actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id); }} type="button">⋮</button></span>
|
||||||
</div>
|
</div>
|
||||||
{renderItems ? <PackageItemsTransition actions={actions} collapsed={row.collapsed} columnOrder={columnOrder} gridTemplate={gridTemplate} id={`downloads-package-items-${entry.id}`} items={row.items} selectedIds={selectedIds} sessionRunning={sessionRunning} /> : null}
|
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -556,7 +486,7 @@ export function arePackageCardPropsEqual(previous: PackageCardProps, next: Packa
|
|||||||
const a = previous.row.package;
|
const a = previous.row.package;
|
||||||
const b = next.row.package;
|
const b = next.row.package;
|
||||||
if (a.id !== b.id || a.updatedAt !== b.updatedAt || a.status !== b.status || a.enabled !== b.enabled || a.name !== b.name || a.priority !== b.priority || a.createdAt !== b.createdAt) return false;
|
if (a.id !== b.id || a.updatedAt !== b.updatedAt || a.status !== b.status || a.enabled !== b.enabled || a.name !== b.name || a.priority !== b.priority || a.createdAt !== b.createdAt) return false;
|
||||||
if (previous.packageSpeedBps !== next.packageSpeedBps || previous.editing !== next.editing || previous.editingName !== next.editingName || previous.row.collapsed !== next.row.collapsed || previous.sessionRunning !== next.sessionRunning || previous.columnOrder !== next.columnOrder || previous.gridTemplate !== next.gridTemplate || previous.renderItems !== next.renderItems || previous.actions !== next.actions) return false;
|
if (previous.packageSpeedBps !== next.packageSpeedBps || previous.editing !== next.editing || previous.editingName !== next.editingName || previous.row.collapsed !== next.row.collapsed || previous.sessionRunning !== next.sessionRunning || previous.columnOrder !== next.columnOrder || previous.gridTemplate !== next.gridTemplate || previous.actions !== next.actions) return false;
|
||||||
if (previous.selectedVersion !== next.selectedVersion || previous.selectedIds !== next.selectedIds) {
|
if (previous.selectedVersion !== next.selectedVersion || previous.selectedIds !== next.selectedIds) {
|
||||||
if (previous.selectedIds.has(a.id) !== next.selectedIds.has(a.id)) return false;
|
if (previous.selectedIds.has(a.id) !== next.selectedIds.has(a.id)) return false;
|
||||||
for (const itemId of b.itemIds) {
|
for (const itemId of b.itemIds) {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ export interface DownloadsStatusModel {
|
|||||||
sessionBytes: number;
|
sessionBytes: number;
|
||||||
total: string;
|
total: string;
|
||||||
totalBytes: number;
|
totalBytes: number;
|
||||||
|
remaining: string;
|
||||||
|
remainingBytes: number;
|
||||||
hosters: number;
|
hosters: number;
|
||||||
speed: string;
|
speed: string;
|
||||||
eta: string;
|
eta: string;
|
||||||
@@ -43,9 +45,10 @@ export interface DownloadsViewModel extends DownloadsViewModelCore {
|
|||||||
editingName: string;
|
editingName: string;
|
||||||
columnOrder: readonly string[];
|
columnOrder: readonly string[];
|
||||||
gridTemplate: string;
|
gridTemplate: string;
|
||||||
sortColumn?: DownloadSortColumn;
|
sortColumn?: DownloadSortColumn;
|
||||||
sortDirection?: "asc" | "desc";
|
sortDirection?: "asc" | "desc";
|
||||||
status: DownloadsStatusModel;
|
disclosureRevision: number;
|
||||||
|
status: DownloadsStatusModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DownloadsViewActions extends DownloadsTableActions {
|
export interface DownloadsViewActions extends DownloadsTableActions {
|
||||||
@@ -106,6 +109,7 @@ export function DownloadsSidebarStatus({ model }: { model: DownloadsViewModel })
|
|||||||
{ label: "Links", metric: "links", numericValue: model.status.links, value: integerFormatter.format(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: "Verbleibend", metric: "remaining", numericValue: model.status.remainingBytes, value: model.status.remaining },
|
||||||
{ label: "Hoster", metric: "hosters", numericValue: model.status.hosters, value: integerFormatter.format(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>;
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactElement } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties, type ReactElement } from "react";
|
||||||
import { buildDownloadLogicalRows, type DownloadLogicalRow } from "./downloads-model";
|
import { buildDownloadLogicalRows, type DownloadLogicalRow } from "./downloads-model";
|
||||||
|
import {
|
||||||
|
activateDownloadDisclosureTransition,
|
||||||
|
DOWNLOAD_DISCLOSURE_DURATION_MS,
|
||||||
|
mergeDownloadDisclosureRows,
|
||||||
|
prepareDownloadDisclosureTransition,
|
||||||
|
type DownloadDisclosureRow
|
||||||
|
} from "./download-disclosure-transition";
|
||||||
import type { DownloadsViewActions, DownloadsViewModel } from "./DownloadsView";
|
import type { DownloadsViewActions, DownloadsViewModel } from "./DownloadsView";
|
||||||
import { ItemRow, PackageCard } from "./DownloadsTable";
|
import { ItemRow, PackageCard } from "./DownloadsTable";
|
||||||
import { calculateDownloadVirtualWindow, DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT, DOWNLOAD_VIRTUAL_OVERSCAN_ROWS } from "./download-virtualizer";
|
import { calculateDownloadVirtualWindow, DOWNLOAD_VIRTUAL_DEFAULT_VIEWPORT_HEIGHT, DOWNLOAD_VIRTUAL_OVERSCAN_ROWS } from "./download-virtualizer";
|
||||||
|
|
||||||
|
const useRendererLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
|
||||||
|
|
||||||
interface DownloadViewportState {
|
interface DownloadViewportState {
|
||||||
scrollTop: number;
|
scrollTop: number;
|
||||||
viewportHeight: number;
|
viewportHeight: number;
|
||||||
@@ -42,13 +51,22 @@ function useDownloadViewport(bodyRef: React.RefObject<HTMLDivElement>): Download
|
|||||||
return viewport;
|
return viewport;
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowStyle(top: number, height: number): CSSProperties {
|
function rowStyle(top: number, height: number, opacity: number): CSSProperties {
|
||||||
return {
|
return {
|
||||||
"--downloads-virtual-row-top": `${top}px`,
|
"--downloads-virtual-row-top": `${top}px`,
|
||||||
"--downloads-virtual-row-height": `${height}px`
|
"--downloads-virtual-row-height": `${height}px`,
|
||||||
|
opacity
|
||||||
} as CSSProperties;
|
} as CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function disclosureClassName(row: DownloadLogicalRow | DownloadDisclosureRow): string {
|
||||||
|
return "disclosurePhase" in row ? ` is-${row.disclosurePhase}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function disclosureOpacity(row: DownloadLogicalRow | DownloadDisclosureRow): number {
|
||||||
|
return "disclosureOpacity" in row && typeof row.disclosureOpacity === "number" ? row.disclosureOpacity : 1;
|
||||||
|
}
|
||||||
|
|
||||||
function renderVirtualRow(row: DownloadLogicalRow, model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement {
|
function renderVirtualRow(row: DownloadLogicalRow, model: DownloadsViewModel, actions: DownloadsViewActions): ReactElement {
|
||||||
if (row.type === "item") {
|
if (row.type === "item") {
|
||||||
return (
|
return (
|
||||||
@@ -63,7 +81,6 @@ function renderVirtualRow(row: DownloadLogicalRow, model: DownloadsViewModel, ac
|
|||||||
editingName={model.editingName}
|
editingName={model.editingName}
|
||||||
gridTemplate={model.gridTemplate}
|
gridTemplate={model.gridTemplate}
|
||||||
packageSpeedBps={model.packageSpeedBps[row.packageId] ?? 0}
|
packageSpeedBps={model.packageSpeedBps[row.packageId] ?? 0}
|
||||||
renderItems={false}
|
|
||||||
row={row.packageRow}
|
row={row.packageRow}
|
||||||
selectedIds={model.selectedIds}
|
selectedIds={model.selectedIds}
|
||||||
selectedVersion={model.actionableSelectedIds.length}
|
selectedVersion={model.actionableSelectedIds.length}
|
||||||
@@ -75,13 +92,51 @@ function renderVirtualRow(row: DownloadLogicalRow, model: DownloadsViewModel, ac
|
|||||||
export function VirtualizedDownloadsBody({ actions, model, state }: { actions: DownloadsViewActions; model: DownloadsViewModel; state: ReactElement | null }): ReactElement {
|
export function VirtualizedDownloadsBody({ actions, model, state }: { actions: DownloadsViewActions; model: DownloadsViewModel; state: ReactElement | null }): ReactElement {
|
||||||
const bodyRef = useRef<HTMLDivElement>(null);
|
const bodyRef = useRef<HTMLDivElement>(null);
|
||||||
const viewport = useDownloadViewport(bodyRef);
|
const viewport = useDownloadViewport(bodyRef);
|
||||||
const logicalRows = useMemo(() => buildDownloadLogicalRows(model), [model]);
|
const desiredRows = useMemo(() => buildDownloadLogicalRows(model), [model]);
|
||||||
const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(logicalRows, {
|
const desiredRowsRef = useRef(desiredRows);
|
||||||
|
desiredRowsRef.current = desiredRows;
|
||||||
|
const previousRowsRef = useRef<readonly DownloadLogicalRow[]>(desiredRows);
|
||||||
|
const transitionRowsRef = useRef<DownloadDisclosureRow[] | null>(null);
|
||||||
|
const [transitionRows, setTransitionRows] = useState<DownloadDisclosureRow[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!transitionRowsRef.current) previousRowsRef.current = desiredRows;
|
||||||
|
}, [desiredRows]);
|
||||||
|
|
||||||
|
useRendererLayoutEffect(() => {
|
||||||
|
const prepared = prepareDownloadDisclosureTransition(transitionRowsRef.current ?? previousRowsRef.current, desiredRowsRef.current);
|
||||||
|
if (!prepared.animated) {
|
||||||
|
transitionRowsRef.current = null;
|
||||||
|
previousRowsRef.current = desiredRowsRef.current;
|
||||||
|
setTransitionRows(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
transitionRowsRef.current = prepared.rows;
|
||||||
|
setTransitionRows(prepared.rows);
|
||||||
|
let settleTimer = 0;
|
||||||
|
const animationFrame = window.requestAnimationFrame(() => {
|
||||||
|
const active = activateDownloadDisclosureTransition(transitionRowsRef.current ?? prepared.rows);
|
||||||
|
transitionRowsRef.current = active;
|
||||||
|
setTransitionRows(active);
|
||||||
|
settleTimer = window.setTimeout(() => {
|
||||||
|
previousRowsRef.current = desiredRowsRef.current;
|
||||||
|
transitionRowsRef.current = null;
|
||||||
|
setTransitionRows(null);
|
||||||
|
}, DOWNLOAD_DISCLOSURE_DURATION_MS);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(animationFrame);
|
||||||
|
if (settleTimer) window.clearTimeout(settleTimer);
|
||||||
|
};
|
||||||
|
}, [model.disclosureRevision, model.displayMode]);
|
||||||
|
|
||||||
|
const renderedRows = useMemo(() => transitionRows ? mergeDownloadDisclosureRows(transitionRows, desiredRows) : desiredRows, [desiredRows, transitionRows]);
|
||||||
|
const virtualWindow = useMemo(() => calculateDownloadVirtualWindow(renderedRows, {
|
||||||
scrollTop: viewport.scrollTop,
|
scrollTop: viewport.scrollTop,
|
||||||
viewportHeight: viewport.viewportHeight,
|
viewportHeight: viewport.viewportHeight,
|
||||||
overscan: DOWNLOAD_VIRTUAL_OVERSCAN_ROWS,
|
overscan: DOWNLOAD_VIRTUAL_OVERSCAN_ROWS,
|
||||||
pinnedIds: [model.editingPackageId]
|
pinnedIds: [model.editingPackageId]
|
||||||
}), [logicalRows, model.editingPackageId, viewport.scrollTop, viewport.viewportHeight]);
|
}), [model.editingPackageId, renderedRows, viewport.scrollTop, viewport.viewportHeight]);
|
||||||
const spacerStyle = { "--downloads-virtual-total-height": `${virtualWindow.totalHeight}px` } as CSSProperties;
|
const spacerStyle = { "--downloads-virtual-total-height": `${virtualWindow.totalHeight}px` } as CSSProperties;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -90,7 +145,7 @@ export function VirtualizedDownloadsBody({ actions, model, state }: { actions: D
|
|||||||
{!state ? (
|
{!state ? (
|
||||||
<div className="downloads-virtual-spacer" style={spacerStyle}>
|
<div className="downloads-virtual-spacer" style={spacerStyle}>
|
||||||
{virtualWindow.rows.map((entry) => (
|
{virtualWindow.rows.map((entry) => (
|
||||||
<div className="downloads-virtual-row" data-download-virtual-index={entry.index} key={`${entry.id}:${entry.index}`} style={rowStyle(entry.top, entry.height)}>
|
<div className={`downloads-virtual-row${disclosureClassName(entry.source)}`} data-download-virtual-index={entry.index} key={`${entry.source.type}:${entry.id}`} style={rowStyle(entry.top, entry.height, disclosureOpacity(entry.source))}>
|
||||||
{renderVirtualRow(entry.source, model, actions)}
|
{renderVirtualRow(entry.source, model, actions)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import type { DownloadLogicalRow } from "./downloads-model";
|
||||||
|
import { DOWNLOAD_FILE_ROW_HEIGHT } from "./download-virtualizer";
|
||||||
|
|
||||||
|
export const DOWNLOAD_DISCLOSURE_DURATION_MS = 300;
|
||||||
|
export const DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS = 64;
|
||||||
|
|
||||||
|
export type DownloadDisclosurePhase = "stable" | "entering" | "leaving";
|
||||||
|
|
||||||
|
export type DownloadDisclosureRow = DownloadLogicalRow & {
|
||||||
|
disclosureOpacity: number;
|
||||||
|
disclosurePhase: DownloadDisclosurePhase;
|
||||||
|
};
|
||||||
|
|
||||||
|
function stableRow(row: DownloadLogicalRow): DownloadDisclosureRow {
|
||||||
|
return { ...row, disclosureOpacity: 1, disclosurePhase: "stable" };
|
||||||
|
}
|
||||||
|
|
||||||
|
type DownloadDisclosureSourceRow = DownloadLogicalRow | DownloadDisclosureRow;
|
||||||
|
|
||||||
|
function disclosureOpacity(row: DownloadDisclosureSourceRow): number {
|
||||||
|
return "disclosureOpacity" in row ? row.disclosureOpacity : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stableDownloadDisclosureRows(rows: readonly DownloadLogicalRow[]): DownloadDisclosureRow[] {
|
||||||
|
return rows.map(stableRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemRowsByPackage(rows: readonly DownloadDisclosureSourceRow[]): Map<string, DownloadDisclosureSourceRow[]> {
|
||||||
|
const grouped = new Map<string, DownloadDisclosureSourceRow[]>();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.type !== "item") continue;
|
||||||
|
const entries = grouped.get(row.packageId) ?? [];
|
||||||
|
entries.push(row);
|
||||||
|
grouped.set(row.packageId, entries);
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prepareDownloadDisclosureTransition(
|
||||||
|
current: readonly DownloadDisclosureSourceRow[],
|
||||||
|
desired: readonly DownloadLogicalRow[]
|
||||||
|
): { animated: boolean; rows: DownloadDisclosureRow[] } {
|
||||||
|
if (!desired.some((row) => row.type === "package")) {
|
||||||
|
return { animated: false, rows: stableDownloadDisclosureRows(desired) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentItems = itemRowsByPackage(current);
|
||||||
|
const currentPackages = new Map(current.filter((row) => row.type === "package").map((row) => [row.packageId, row]));
|
||||||
|
const desiredItems = new Map<string, DownloadLogicalRow[]>();
|
||||||
|
for (const row of desired) {
|
||||||
|
if (row.type !== "item") continue;
|
||||||
|
const entries = desiredItems.get(row.packageId) ?? [];
|
||||||
|
entries.push(row);
|
||||||
|
desiredItems.set(row.packageId, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
let animatedItemCount = 0;
|
||||||
|
for (const row of desired) {
|
||||||
|
if (row.type !== "package") continue;
|
||||||
|
const previousPackage = currentPackages.get(row.packageId);
|
||||||
|
if (previousPackage?.type !== "package" || previousPackage.packageRow.collapsed === row.packageRow.collapsed) continue;
|
||||||
|
animatedItemCount += Math.max(currentItems.get(row.packageId)?.length ?? 0, desiredItems.get(row.packageId)?.length ?? 0);
|
||||||
|
if (animatedItemCount > DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS) {
|
||||||
|
return { animated: false, rows: stableDownloadDisclosureRows(desired) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: DownloadDisclosureRow[] = [];
|
||||||
|
let animated = false;
|
||||||
|
for (const row of desired) {
|
||||||
|
if (row.type !== "package") continue;
|
||||||
|
rows.push(stableRow(row));
|
||||||
|
const before = currentItems.get(row.packageId) ?? [];
|
||||||
|
const after = desiredItems.get(row.packageId) ?? [];
|
||||||
|
const previousPackage = currentPackages.get(row.packageId);
|
||||||
|
const disclosureChanged = previousPackage?.type === "package" && previousPackage.packageRow.collapsed !== row.packageRow.collapsed;
|
||||||
|
if (!disclosureChanged) {
|
||||||
|
rows.push(...after.map(stableRow));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.packageRow.collapsed && before.length > 0) {
|
||||||
|
animated = true;
|
||||||
|
rows.push(...before.map((entry) => ({ ...entry, disclosureOpacity: disclosureOpacity(entry), disclosurePhase: "leaving" as const })));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const beforeById = new Map(before.map((entry) => [entry.id, entry]));
|
||||||
|
for (const entry of after) {
|
||||||
|
const previous = beforeById.get(entry.id);
|
||||||
|
if (!previous) {
|
||||||
|
animated = true;
|
||||||
|
rows.push({ ...entry, height: 0, disclosureOpacity: 0, disclosurePhase: "entering" });
|
||||||
|
} else if (("disclosurePhase" in previous && previous.disclosurePhase === "leaving") || previous.height === 0) {
|
||||||
|
animated = true;
|
||||||
|
rows.push({ ...entry, height: previous.height, disclosureOpacity: disclosureOpacity(previous), disclosurePhase: "entering" });
|
||||||
|
} else {
|
||||||
|
rows.push(stableRow(entry));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { animated, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeDownloadDisclosureRows(
|
||||||
|
transition: readonly DownloadDisclosureRow[],
|
||||||
|
desired: readonly DownloadLogicalRow[]
|
||||||
|
): DownloadDisclosureRow[] {
|
||||||
|
const transitionById = new Map(transition.map((row) => [`${row.type}:${row.id}`, row]));
|
||||||
|
const desiredIds = new Set(desired.map((row) => `${row.type}:${row.id}`));
|
||||||
|
const leavingByPackage = new Map<string, DownloadDisclosureRow[]>();
|
||||||
|
for (const row of transition) {
|
||||||
|
if (row.type !== "item" || row.disclosurePhase !== "leaving" || desiredIds.has(`item:${row.id}`)) continue;
|
||||||
|
const entries = leavingByPackage.get(row.packageId) ?? [];
|
||||||
|
entries.push(row);
|
||||||
|
leavingByPackage.set(row.packageId, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: DownloadDisclosureRow[] = [];
|
||||||
|
for (const row of desired) {
|
||||||
|
const animated = transitionById.get(`${row.type}:${row.id}`);
|
||||||
|
rows.push(animated
|
||||||
|
? { ...row, height: animated.height, disclosureOpacity: animated.disclosureOpacity, disclosurePhase: animated.disclosurePhase }
|
||||||
|
: stableRow(row));
|
||||||
|
if (row.type === "package") rows.push(...(leavingByPackage.get(row.packageId) ?? []));
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function activateDownloadDisclosureTransition(rows: readonly DownloadDisclosureRow[]): DownloadDisclosureRow[] {
|
||||||
|
return rows.map((row) => {
|
||||||
|
if (row.type !== "item" || row.disclosurePhase === "stable") return row;
|
||||||
|
return row.disclosurePhase === "entering"
|
||||||
|
? { ...row, height: DOWNLOAD_FILE_ROW_HEIGHT, disclosureOpacity: 1 }
|
||||||
|
: { ...row, height: 0, disclosureOpacity: 0 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types";
|
import type { DownloadItem, DownloadStatus, PackageEntry } from "../../../shared/types";
|
||||||
|
import { humanSize } from "../../download-format";
|
||||||
import { DOWNLOAD_FILE_ROW_HEIGHT, DOWNLOAD_PACKAGE_ROW_HEIGHT, type DownloadVirtualRowInput } from "./download-virtualizer";
|
import { DOWNLOAD_FILE_ROW_HEIGHT, DOWNLOAD_PACKAGE_ROW_HEIGHT, type DownloadVirtualRowInput } from "./download-virtualizer";
|
||||||
|
|
||||||
export type DownloadDisplayMode = "packages" | "files";
|
export type DownloadDisplayMode = "packages" | "files";
|
||||||
@@ -89,14 +90,37 @@ export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): numbe
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPendingDownloadItem(item: DownloadItem): boolean {
|
||||||
|
return item.status !== "completed" && item.status !== "cancelled" && item.status !== "failed";
|
||||||
|
}
|
||||||
|
|
||||||
export function getPendingDownloadItemCount(items: Iterable<DownloadItem>): number {
|
export function getPendingDownloadItemCount(items: Iterable<DownloadItem>): number {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.status !== "completed" && item.status !== "cancelled" && item.status !== "failed") count += 1;
|
if (isPendingDownloadItem(item)) count += 1;
|
||||||
}
|
}
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRemainingDownloadBytes(items: Iterable<DownloadItem>): { bytes: number; unknownItems: number } {
|
||||||
|
let bytes = 0;
|
||||||
|
let unknownItems = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
if (!isPendingDownloadItem(item)) continue;
|
||||||
|
if (!item.totalBytes || item.totalBytes <= 0) {
|
||||||
|
unknownItems += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bytes += Math.max(0, item.totalBytes - Math.max(0, item.downloadedBytes));
|
||||||
|
}
|
||||||
|
return { bytes, unknownItems };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRemainingDownloadBytes(summary: { bytes: number; unknownItems: number }): string {
|
||||||
|
if (summary.unknownItems <= 0) return humanSize(summary.bytes);
|
||||||
|
return summary.bytes > 0 ? `≥ ${humanSize(summary.bytes)}` : "Unbekannt";
|
||||||
|
}
|
||||||
|
|
||||||
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)) {
|
||||||
|
|||||||
@@ -290,6 +290,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
height: var(--downloads-virtual-total-height);
|
height: var(--downloads-virtual-total-height);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
|
transition: height 300ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-virtual-row {
|
.downloads-virtual-row {
|
||||||
@@ -297,6 +298,8 @@
|
|||||||
inset: 0 0 auto 0;
|
inset: 0 0 auto 0;
|
||||||
height: var(--downloads-virtual-row-height);
|
height: var(--downloads-virtual-row-height);
|
||||||
transform: translateY(var(--downloads-virtual-row-top));
|
transform: translateY(var(--downloads-virtual-row-top));
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height 300ms cubic-bezier(0.22, 1, 0.36, 1), transform 300ms cubic-bezier(0.22, 1, 0.36, 1), opacity 300ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.downloads-virtual-row > .downloads-package-card {
|
.downloads-virtual-row > .downloads-package-card {
|
||||||
|
|||||||
@@ -9,13 +9,22 @@ import {
|
|||||||
buildDownloadSidebarCounts,
|
buildDownloadSidebarCounts,
|
||||||
buildDownloadsViewModel,
|
buildDownloadsViewModel,
|
||||||
classifyDownloadStatus,
|
classifyDownloadStatus,
|
||||||
|
formatRemainingDownloadBytes,
|
||||||
getDownloadQueueTotalBytes,
|
getDownloadQueueTotalBytes,
|
||||||
|
getRemainingDownloadBytes,
|
||||||
getPendingDownloadItemCount,
|
getPendingDownloadItemCount,
|
||||||
getDownloadSpeedBps,
|
getDownloadSpeedBps,
|
||||||
buildDownloadLogicalRows,
|
buildDownloadLogicalRows,
|
||||||
type DownloadSidebarFilter,
|
type DownloadSidebarFilter,
|
||||||
type DownloadsModelInput
|
type DownloadsModelInput
|
||||||
} from "../src/renderer/views/downloads/downloads-model";
|
} from "../src/renderer/views/downloads/downloads-model";
|
||||||
|
import {
|
||||||
|
DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS,
|
||||||
|
activateDownloadDisclosureTransition,
|
||||||
|
mergeDownloadDisclosureRows,
|
||||||
|
prepareDownloadDisclosureTransition,
|
||||||
|
stableDownloadDisclosureRows
|
||||||
|
} from "../src/renderer/views/downloads/download-disclosure-transition";
|
||||||
import {
|
import {
|
||||||
DownloadsContent,
|
DownloadsContent,
|
||||||
DownloadsFooter,
|
DownloadsFooter,
|
||||||
@@ -98,9 +107,10 @@ describe("rollende Downloadkennzahlen", () => {
|
|||||||
expect(getRollingMetricDirection(300, 300)).toBe("none");
|
expect(getRollingMetricDirection(300, 300)).toBe("none");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("animates exactly the five stable sidebar metrics", () => {
|
it("animates exactly the six stable sidebar metrics including the remaining volume", () => {
|
||||||
const html = renderToStaticMarkup(<DownloadsSidebarStatus model={withRuntime(createInput())} />);
|
const html = renderToStaticMarkup(<DownloadsSidebarStatus model={withRuntime(createInput())} />);
|
||||||
expect(html.match(/class="downloads-rolling-value"/g)).toHaveLength(5);
|
expect(html.match(/class="downloads-rolling-value"/g)).toHaveLength(6);
|
||||||
|
expect(html).toContain("Verbleibend");
|
||||||
expect(html).toContain('data-status-metric="speed"');
|
expect(html).toContain('data-status-metric="speed"');
|
||||||
expect(html).toContain('data-status-metric="eta"');
|
expect(html).toContain('data-status-metric="eta"');
|
||||||
});
|
});
|
||||||
@@ -163,6 +173,171 @@ describe("Download-Gesamtgröße", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("verbleibendes Downloadvolumen", () => {
|
||||||
|
it("summiert nur offene bekannte Restbytes und klemmt überladene Fortschritte bei null", () => {
|
||||||
|
const items = [
|
||||||
|
item("partial", "remaining", "downloading", { downloadedBytes: 750_000_000, totalBytes: 2_000_000_000 }),
|
||||||
|
item("queued", "remaining", "queued", { downloadedBytes: 0, totalBytes: 3_000_000_000 }),
|
||||||
|
item("overshot", "remaining", "downloading", { downloadedBytes: 2_100_000_000, totalBytes: 2_000_000_000 }),
|
||||||
|
item("done", "remaining", "completed", { downloadedBytes: 9_000_000_000, totalBytes: 10_000_000_000 }),
|
||||||
|
item("failed", "remaining", "failed", { downloadedBytes: 0, totalBytes: 8_000_000_000 }),
|
||||||
|
item("cancelled", "remaining", "cancelled", { downloadedBytes: 0, totalBytes: 7_000_000_000 })
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getRemainingDownloadBytes(items)).toEqual({ bytes: 4_250_000_000, unknownItems: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("meldet unbekannte Größen nur für noch offene Downloads", () => {
|
||||||
|
const items = [
|
||||||
|
item("known", "remaining", "queued", { downloadedBytes: 250_000_000, totalBytes: 1_000_000_000 }),
|
||||||
|
item("unknown-open", "remaining", "queued", { downloadedBytes: 120_000_000, totalBytes: null }),
|
||||||
|
item("unknown-done", "remaining", "completed", { totalBytes: null })
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(getRemainingDownloadBytes(items)).toEqual({ bytes: 750_000_000, unknownItems: 1 });
|
||||||
|
expect(formatRemainingDownloadBytes({ bytes: 750_000_000, unknownItems: 1 })).toBe("≥ 715.26 MB");
|
||||||
|
expect(formatRemainingDownloadBytes({ bytes: 0, unknownItems: 1 })).toBe("Unbekannt");
|
||||||
|
expect(formatRemainingDownloadBytes({ bytes: 0, unknownItems: 0 })).toBe("0 B");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("virtualisierte Paketanimation", () => {
|
||||||
|
const packageA = pkg("package-a", "A", ["a-1", "a-2"]);
|
||||||
|
const packageB = pkg("package-b", "B", ["b-1"]);
|
||||||
|
const items = {
|
||||||
|
"a-1": item("a-1", "package-a", "queued"),
|
||||||
|
"a-2": item("a-2", "package-a", "queued"),
|
||||||
|
"b-1": item("b-1", "package-b", "queued")
|
||||||
|
};
|
||||||
|
const logicalRows = (collapsedPackageIds: string[]) => buildDownloadLogicalRows(buildDownloadsViewModel({
|
||||||
|
packageOrder: [packageA.id, packageB.id],
|
||||||
|
packages: { [packageA.id]: packageA, [packageB.id]: packageB },
|
||||||
|
items,
|
||||||
|
displayMode: "packages",
|
||||||
|
filter: "all",
|
||||||
|
providerFilter: "all",
|
||||||
|
query: "",
|
||||||
|
collapsedPackageIds,
|
||||||
|
selectedIds: [],
|
||||||
|
hideExtractedItems: false,
|
||||||
|
showAllPackages: true,
|
||||||
|
renderLimit: 100
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("fährt neue Unterzeilen von null auf ihre feste Höhe aus und verschiebt Folgepakete unter stabilen IDs", () => {
|
||||||
|
const collapsed = logicalRows([packageA.id]);
|
||||||
|
const expanded = logicalRows([]);
|
||||||
|
const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(collapsed), expanded);
|
||||||
|
|
||||||
|
expect(prepared.animated).toBe(true);
|
||||||
|
expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([
|
||||||
|
["package-a", 40, "stable", 1],
|
||||||
|
["a-1", 0, "entering", 0],
|
||||||
|
["a-2", 0, "entering", 0],
|
||||||
|
["package-b", 40, "stable", 1],
|
||||||
|
["b-1", 38, "stable", 1]
|
||||||
|
]);
|
||||||
|
expect(activateDownloadDisclosureTransition(prepared.rows).map((row) => [row.id, row.height, row.disclosureOpacity])).toEqual([
|
||||||
|
["package-a", 40, 1],
|
||||||
|
["a-1", 38, 1],
|
||||||
|
["a-2", 38, 1],
|
||||||
|
["package-b", 40, 1],
|
||||||
|
["b-1", 38, 1]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("behält ausfahrende Unterzeilen bis zum Animationsende und reduziert ihre Höhe auf null", () => {
|
||||||
|
const expanded = logicalRows([]);
|
||||||
|
const collapsed = logicalRows([packageA.id]);
|
||||||
|
const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(expanded), collapsed);
|
||||||
|
|
||||||
|
expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([
|
||||||
|
["package-a", 40, "stable", 1],
|
||||||
|
["a-1", 38, "leaving", 1],
|
||||||
|
["a-2", 38, "leaving", 1],
|
||||||
|
["package-b", 40, "stable", 1],
|
||||||
|
["b-1", 38, "stable", 1]
|
||||||
|
]);
|
||||||
|
expect(activateDownloadDisclosureTransition(prepared.rows).map((row) => [row.id, row.height, row.disclosureOpacity])).toEqual([
|
||||||
|
["package-a", 40, 1],
|
||||||
|
["a-1", 0, 0],
|
||||||
|
["a-2", 0, 0],
|
||||||
|
["package-b", 40, 1],
|
||||||
|
["b-1", 38, 1]
|
||||||
|
]);
|
||||||
|
expect(stableDownloadDisclosureRows(collapsed).map((row) => row.id)).toEqual(["package-a", "package-b", "b-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("überspringt die Bewegung bei riesigen Einzelpaketen und hält das DOM-Fenster begrenzt", () => {
|
||||||
|
const count = DOWNLOAD_DISCLOSURE_MAX_ANIMATED_ITEMS + 10_000;
|
||||||
|
const hugePackage = pkg("huge-package", "Groß", Array.from({ length: count }, (_, index) => `huge-${index}`));
|
||||||
|
const hugeItems = Object.fromEntries(hugePackage.itemIds.map((id) => [id, item(id, hugePackage.id, "queued")]));
|
||||||
|
const buildRows = (collapsed: boolean) => buildDownloadLogicalRows(buildDownloadsViewModel({
|
||||||
|
packageOrder: [hugePackage.id],
|
||||||
|
packages: { [hugePackage.id]: hugePackage },
|
||||||
|
items: hugeItems,
|
||||||
|
displayMode: "packages",
|
||||||
|
filter: "all",
|
||||||
|
providerFilter: "all",
|
||||||
|
query: "",
|
||||||
|
collapsedPackageIds: collapsed ? [hugePackage.id] : [],
|
||||||
|
selectedIds: [],
|
||||||
|
hideExtractedItems: false,
|
||||||
|
showAllPackages: true,
|
||||||
|
renderLimit: count + 1
|
||||||
|
}));
|
||||||
|
const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(buildRows(true)), buildRows(false));
|
||||||
|
const window = calculateDownloadVirtualWindow(prepared.rows, { scrollTop: 0, viewportHeight: 720, overscan: 8 });
|
||||||
|
|
||||||
|
expect(prepared.animated).toBe(false);
|
||||||
|
expect(window.rows.length).toBeLessThan(40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("begrenzt gleichzeitig animierte Unterzeilen über alle Pakete hinweg", () => {
|
||||||
|
const manyItems = Object.fromEntries(Array.from({ length: 80 }, (_, index) => {
|
||||||
|
const packageId = index < 40 ? "many-a" : "many-b";
|
||||||
|
const id = `${packageId}-${index}`;
|
||||||
|
return [id, item(id, packageId, "queued")];
|
||||||
|
}));
|
||||||
|
const manyPackages = {
|
||||||
|
"many-a": pkg("many-a", "Viele A", Object.keys(manyItems).filter((id) => id.startsWith("many-a"))),
|
||||||
|
"many-b": pkg("many-b", "Viele B", Object.keys(manyItems).filter((id) => id.startsWith("many-b")))
|
||||||
|
};
|
||||||
|
const buildRows = (collapsedPackageIds: string[]) => buildDownloadLogicalRows(buildDownloadsViewModel({
|
||||||
|
packageOrder: ["many-a", "many-b"],
|
||||||
|
packages: manyPackages,
|
||||||
|
items: manyItems,
|
||||||
|
displayMode: "packages",
|
||||||
|
filter: "all",
|
||||||
|
providerFilter: "all",
|
||||||
|
query: "",
|
||||||
|
collapsedPackageIds,
|
||||||
|
selectedIds: [],
|
||||||
|
hideExtractedItems: false,
|
||||||
|
showAllPackages: true,
|
||||||
|
renderLimit: 100
|
||||||
|
}));
|
||||||
|
|
||||||
|
const prepared = prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(buildRows(["many-a", "many-b"])), buildRows([]));
|
||||||
|
expect(prepared.animated).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("übernimmt Laufzeitupdates während der Bewegung und kehrt schnelle Gegenklicks um", () => {
|
||||||
|
const entering = activateDownloadDisclosureTransition(prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(logicalRows([packageA.id])), logicalRows([])).rows);
|
||||||
|
const updated = logicalRows([]).map((row) => row.type === "item" && row.id === "a-1" ? { ...row, item: { ...row.item, downloadedBytes: 987_654_321 } } : row);
|
||||||
|
const merged = mergeDownloadDisclosureRows(entering, updated);
|
||||||
|
const updatedItem = merged.find((row) => row.type === "item" && row.id === "a-1");
|
||||||
|
expect(updatedItem?.type === "item" ? updatedItem.item.downloadedBytes : 0).toBe(987_654_321);
|
||||||
|
expect(updatedItem).toEqual(expect.objectContaining({ disclosurePhase: "entering", height: 38, disclosureOpacity: 1 }));
|
||||||
|
|
||||||
|
const collapsed = logicalRows([packageA.id]);
|
||||||
|
const leaving = activateDownloadDisclosureTransition(prepareDownloadDisclosureTransition(stableDownloadDisclosureRows(logicalRows([])), collapsed).rows);
|
||||||
|
const reversed = prepareDownloadDisclosureTransition(leaving, logicalRows([]));
|
||||||
|
expect(reversed.animated).toBe(true);
|
||||||
|
expect(reversed.rows.filter((row) => row.packageId === packageA.id && row.type === "item").every((row) => row.disclosurePhase === "entering")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("laufender Queue-Linkzähler", () => {
|
describe("laufender Queue-Linkzähler", () => {
|
||||||
it("sinkt sofort, sobald eine Unterdatei abgeschlossen ist", () => {
|
it("sinkt sofort, sobald eine Unterdatei abgeschlossen ist", () => {
|
||||||
const items = [
|
const items = [
|
||||||
@@ -360,6 +535,7 @@ function withRuntime(input: DownloadsModelInput, overrides: Record<string, unkno
|
|||||||
scheduleTime: "23:30",
|
scheduleTime: "23:30",
|
||||||
scheduleLabel: "",
|
scheduleLabel: "",
|
||||||
packageSpeedBps: { "package-a": 12_000_000 },
|
packageSpeedBps: { "package-a": 12_000_000 },
|
||||||
|
disclosureRevision: 0,
|
||||||
editingPackageId: null,
|
editingPackageId: null,
|
||||||
editingName: "",
|
editingName: "",
|
||||||
columnOrder: ["name", "size", "hoster", "progress"] as const,
|
columnOrder: ["name", "size", "hoster", "progress"] as const,
|
||||||
@@ -371,6 +547,8 @@ function withRuntime(input: DownloadsModelInput, overrides: Record<string, unkno
|
|||||||
sessionBytes: 3_000_000_000,
|
sessionBytes: 3_000_000_000,
|
||||||
total: "10,00 GB",
|
total: "10,00 GB",
|
||||||
totalBytes: 10_000_000_000,
|
totalBytes: 10_000_000_000,
|
||||||
|
remaining: "6,50 GB",
|
||||||
|
remainingBytes: 6_500_000_000,
|
||||||
hosters: 3,
|
hosters: 3,
|
||||||
speed: "96,00 Mbit/s",
|
speed: "96,00 Mbit/s",
|
||||||
eta: "00:05:00"
|
eta: "00:05:00"
|
||||||
@@ -794,7 +972,8 @@ describe("downloads view", () => {
|
|||||||
expect(css).toMatch(/:is\(\.downloads-status-full, \.downloads-status-compact, \.downloads-service-full, \.downloads-service-compact\)\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;/s);
|
expect(css).toMatch(/:is\(\.downloads-status-full, \.downloads-status-compact, \.downloads-service-full, \.downloads-service-compact\)\s*\{[^}]*min-width:\s*0;[^}]*overflow:\s*hidden;[^}]*text-overflow:\s*ellipsis;[^}]*white-space:\s*nowrap;/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*block;/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*block;/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*block;/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*block;/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(css).toMatch(/\.downloads-virtual-spacer\s*\{[^}]*transition:\s*height 300ms cubic-bezier\(0\.22, 1, 0\.36, 1\);/s);
|
||||||
|
expect(css).toMatch(/\.downloads-virtual-row\s*\{[^}]*transition:\s*height 300ms[^;]*transform 300ms[^;]*opacity 300ms/s);
|
||||||
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);
|
||||||
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*box-shadow:\s*none;/s);
|
expect(css).toMatch(/\.downloads-package-card\s*\{[^}]*box-shadow:\s*none;/s);
|
||||||
@@ -821,7 +1000,7 @@ describe("downloads view", () => {
|
|||||||
expect(css).toMatch(/\.downloads-selection-cell\s+input\[type="checkbox"\]\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;/s);
|
expect(css).toMatch(/\.downloads-selection-cell\s+input\[type="checkbox"\]\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;/s);
|
||||||
expect(css).toMatch(/\.downloads-hoster-icon\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;[^}]*object-fit:\s*contain;/s);
|
expect(css).toMatch(/\.downloads-hoster-icon\s*\{[^}]*width:\s*18px;[^}]*height:\s*18px;[^}]*object-fit:\s*contain;/s);
|
||||||
expect(css).toMatch(/\.downloads-hoster-icon\[data-hoster="rapidgator"\]\s*\{[^}]*transform:\s*translateY\(-4px\) scale\(2\);/s);
|
expect(css).toMatch(/\.downloads-hoster-icon\[data-hoster="rapidgator"\]\s*\{[^}]*transform:\s*translateY\(-4px\) scale\(2\);/s);
|
||||||
expect(source.match(/duration:\s*300/g)).toHaveLength(2);
|
expect(source).not.toContain("PackageItemsTransition");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the action column visible at 1366px and 1120px through the production wrapper contract", () => {
|
it("keeps the action column visible at 1366px and 1120px through the production wrapper contract", () => {
|
||||||
@@ -1277,7 +1456,7 @@ describe("download table row contracts", () => {
|
|||||||
|
|
||||||
expect(calls).toEqual(["select", "prevent", "collapse", "collapse"]);
|
expect(calls).toEqual(["select", "prevent", "collapse", "collapse"]);
|
||||||
expect(collapseButton.props["aria-expanded"]).toBe(true);
|
expect(collapseButton.props["aria-expanded"]).toBe(true);
|
||||||
expect(collapseButton.props["aria-controls"]).toBe(`downloads-package-items-${row.package.id}`);
|
expect(collapseButton.props["aria-controls"]).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not collapse a package when a row double click starts on interactive content", () => {
|
it("does not collapse a package when a row double click starts on interactive content", () => {
|
||||||
|
|||||||
+2
-1
@@ -148,7 +148,8 @@ describe("renderer localization", () => {
|
|||||||
["3 ausgewählt", "3 selected"],
|
["3 ausgewählt", "3 selected"],
|
||||||
["vor 4 Std", "4 hr ago"],
|
["vor 4 Std", "4 hr ago"],
|
||||||
["Zeitregel 4", "Schedule rule 4"],
|
["Zeitregel 4", "Schedule rule 4"],
|
||||||
["1.5 GB von 10 GB übrig", "1.5 GB of 10 GB remaining"]
|
["1.5 GB von 10 GB übrig", "1.5 GB of 10 GB remaining"],
|
||||||
|
["Verbleibend", "Remaining"]
|
||||||
])("translates composed renderer text %s without changing its payload", (german, english) => {
|
])("translates composed renderer text %s without changing its payload", (german, english) => {
|
||||||
expect(translateUiText(german, "en")).toBe(english);
|
expect(translateUiText(german, "en")).toBe(english);
|
||||||
expect(translateUiText(english, "de")).toBe(german);
|
expect(translateUiText(english, "de")).toBe(german);
|
||||||
|
|||||||
Reference in New Issue
Block a user