Prepare v2.0.74 sidepanel reliability release
Restore and persist the package-based link collector with progressive metadata, bounded high-volume updates, safe hydration, visible selection, and complete localization. Harden download controls, snapshot ordering, history pagination, statistics recovery, settings saves, backup imports, notification persistence, and Windows storage races with regression coverage.
This commit is contained in:
@@ -79,8 +79,99 @@ export interface DownloadsViewActions extends DownloadsTableActions {
|
||||
onToggleClipboardWatcher: () => void;
|
||||
onClearAll: () => void;
|
||||
onToggleAllPackages: () => void;
|
||||
onShowAllPackages: () => void;
|
||||
}
|
||||
onShowAllPackages: () => void;
|
||||
}
|
||||
|
||||
export interface DownloadContextStartActionsProps {
|
||||
actionBusy: boolean;
|
||||
canStart: boolean;
|
||||
showSelected: boolean;
|
||||
selectedLabel: string;
|
||||
onStartSelected: () => void;
|
||||
onStartAll: () => void;
|
||||
}
|
||||
|
||||
export async function runDownloadStartAction(
|
||||
canStart: boolean,
|
||||
action: () => Promise<void>,
|
||||
onBlocked: () => void,
|
||||
onError: (error: unknown) => void
|
||||
): Promise<boolean> {
|
||||
if (!canStart) {
|
||||
onBlocked();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await action();
|
||||
return true;
|
||||
} catch (error) {
|
||||
onError(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resumeDownloadSession(
|
||||
canStart: boolean,
|
||||
togglePause: () => Promise<boolean>,
|
||||
applyPaused: (paused: boolean) => void,
|
||||
onBlocked: () => void,
|
||||
onError: (error: unknown) => void
|
||||
): Promise<boolean> {
|
||||
return runDownloadStartAction(canStart, async () => {
|
||||
applyPaused(await togglePause());
|
||||
}, onBlocked, onError);
|
||||
}
|
||||
|
||||
export async function pauseDownloadSession(
|
||||
togglePause: () => Promise<boolean>,
|
||||
applyPaused: (paused: boolean) => void,
|
||||
onError: (error: unknown) => void
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
applyPaused(await togglePause());
|
||||
return true;
|
||||
} catch (error) {
|
||||
onError(error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function runResumeDownloadAction(
|
||||
runSingleFlight: (action: () => Promise<unknown>) => Promise<void>,
|
||||
canStart: boolean,
|
||||
togglePause: () => Promise<boolean>,
|
||||
applyPaused: (paused: boolean) => void,
|
||||
onBlocked: () => void,
|
||||
onError: (error: unknown) => void
|
||||
): Promise<void> {
|
||||
return runSingleFlight(() => resumeDownloadSession(canStart, togglePause, applyPaused, onBlocked, onError));
|
||||
}
|
||||
|
||||
export function runPauseDownloadAction(
|
||||
runSingleFlight: (action: () => Promise<unknown>) => Promise<void>,
|
||||
togglePause: () => Promise<boolean>,
|
||||
applyPaused: (paused: boolean) => void,
|
||||
onError: (error: unknown) => void
|
||||
): Promise<void> {
|
||||
return runSingleFlight(() => pauseDownloadSession(togglePause, applyPaused, onError));
|
||||
}
|
||||
|
||||
export function DownloadContextStartActions({
|
||||
actionBusy,
|
||||
canStart,
|
||||
showSelected,
|
||||
selectedLabel,
|
||||
onStartSelected,
|
||||
onStartAll
|
||||
}: DownloadContextStartActionsProps): ReactElement {
|
||||
const disabled = !canStart || actionBusy;
|
||||
return (
|
||||
<>
|
||||
{showSelected ? <button className="ctx-menu-item" disabled={disabled} onClick={onStartSelected}>{selectedLabel}</button> : null}
|
||||
<button className="ctx-menu-item" disabled={disabled} onClick={onStartAll}>Alle Downloads starten</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [
|
||||
{ id: "all", label: "Alle" },
|
||||
@@ -95,7 +186,7 @@ export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewAct
|
||||
return (
|
||||
<aside className="downloads-sidebar" data-visual-region="downloads-sidebar">
|
||||
<SlidingSelection activeKey={model.filter} aria-label="Downloadfilter" as="nav" axis="vertical" className="downloads-filter-group">
|
||||
{filters.map((filter) => <button aria-current={model.filter === filter.id ? "page" : undefined} className={model.filter === filter.id ? "is-active" : ""} data-sliding-selection-active={model.filter === filter.id} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{model.counts[filter.id]}</b></button>)}
|
||||
{filters.map((filter) => <button aria-current={model.filter === filter.id ? "page" : undefined} className={model.filter === filter.id ? "is-active" : ""} data-sliding-selection-active={model.filter === filter.id} data-sliding-selection-item="true" key={filter.id} onClick={() => actions.onFilterChange(filter.id)} type="button"><span>{filter.label}</span><b>{integerFormatter.format(model.counts[filter.id])}</b></button>)}
|
||||
</SlidingSelection>
|
||||
<label className="downloads-provider-filter"><span>Service</span><select aria-label="Service filtern" disabled={model.providerOptions.length <= 1} onChange={(event) => actions.onProviderFilterChange(event.target.value)} value={model.providerFilter}><option value="all">Alle Services</option>{model.providerOptions.map((provider) => <option key={provider.id} value={provider.id}>{provider.label}</option>)}</select></label>
|
||||
<label className="downloads-sidebar-search"><span>Downloads durchsuchen</span><input className="downloads-search-input" onChange={(event) => actions.onQueryChange(event.target.value)} placeholder="Paket, Datei oder Service" type="search" value={model.query} /></label>
|
||||
@@ -133,7 +224,7 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
|
||||
return (
|
||||
<div className="downloads-toolbar" data-visual-region="downloads-toolbar">
|
||||
<button disabled={model.actionBusy || !model.canStart} onClick={actions.onStartDownloads} type="button">Start</button>
|
||||
<button disabled={!model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
|
||||
<button disabled={model.actionBusy || !model.canPause || model.paused} onClick={actions.onPauseDownloads} type="button">Pause</button>
|
||||
<button disabled={!model.canStop || model.actionBusy} onClick={actions.onStopDownloads} type="button">Stop</button>
|
||||
{!model.scheduleActive ? <button aria-expanded={model.scheduleOpen} onClick={actions.onToggleSchedule} type="button">Zeitplan</button> : null}
|
||||
<span className={scheduleSlotClass}>
|
||||
@@ -149,15 +240,16 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct
|
||||
<button disabled={!onePackage} onClick={actions.onRenameSelection} type="button">Umbenennen</button>
|
||||
<button disabled={!hasSelection} onClick={actions.onRemoveSelection} type="button">Entfernen</button>
|
||||
<span aria-label="Paketdarstellung" className="downloads-toolbar-tail" role="group">
|
||||
<button className="downloads-toolbar-toggle-all" disabled={model.empty} onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
||||
<button className="downloads-toolbar-toggle-all" disabled={model.presentationEmpty} onClick={actions.onToggleAllPackages} type="button">Alle ein-/ausklappen</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function tableState(model: DownloadsViewModel): ReactElement | null {
|
||||
if (model.empty) return <div className="downloads-empty-state" data-visual-region="downloads-empty-state" role="row"><div role="cell"><strong>Noch keine Downloads</strong><span>Füge Links hinzu, um den ersten Download zu starten.</span></div></div>;
|
||||
if (model.filteredEmpty) return <div className="downloads-table-message" role="row"><div role="cell"><strong>Keine passenden Downloads</strong><span>Passe Filter oder Suche an.</span></div></div>;
|
||||
function tableState(model: DownloadsViewModel): ReactElement | null {
|
||||
if (model.empty) return <div className="downloads-empty-state" data-visual-region="downloads-empty-state" role="row"><div role="cell"><strong>Noch keine Downloads</strong><span>Füge Links hinzu, um den ersten Download zu starten.</span></div></div>;
|
||||
if (model.presentationEmpty) return <div className="downloads-table-message" role="row"><div role="cell"><strong>Entpackte Downloads sind ausgeblendet</strong><span>Deaktiviere „Entpackte Einträge ausblenden“, um sie wieder anzuzeigen.</span></div></div>;
|
||||
if (model.filteredEmpty) return <div className="downloads-table-message" role="row"><div role="cell"><strong>Keine passenden Downloads</strong><span>Passe Filter oder Suche an.</span></div></div>;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,11 +55,18 @@ export interface DownloadsViewModelCore {
|
||||
mainRowCount: number;
|
||||
totalMainRowCount: number;
|
||||
paginationLabel: string;
|
||||
limited: boolean;
|
||||
empty: boolean;
|
||||
limited: boolean;
|
||||
sourceEmpty: boolean;
|
||||
presentationEmpty: boolean;
|
||||
empty: boolean;
|
||||
filteredEmpty: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadByteSummary {
|
||||
bytes: number;
|
||||
unknownItems: number;
|
||||
}
|
||||
|
||||
export type DownloadLogicalRow =
|
||||
| (DownloadVirtualRowInput & { type: "package"; packageId: string; packageRow: DownloadPackageRow })
|
||||
| (DownloadVirtualRowInput & { type: "item"; packageId: string; item: DownloadItem });
|
||||
@@ -84,12 +91,18 @@ export function buildDownloadSidebarCounts(items: Iterable<DownloadItem>): Downl
|
||||
return counts;
|
||||
}
|
||||
|
||||
export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): number {
|
||||
let total = 0;
|
||||
export function getDownloadQueueTotalBytes(items: Iterable<DownloadItem>): DownloadByteSummary {
|
||||
let bytes = 0;
|
||||
let unknownItems = 0;
|
||||
for (const item of items) {
|
||||
total += item.totalBytes || item.downloadedBytes || 0;
|
||||
if (item.totalBytes && item.totalBytes > 0) {
|
||||
bytes += item.totalBytes;
|
||||
} else {
|
||||
bytes += Math.max(0, item.downloadedBytes || 0);
|
||||
unknownItems += 1;
|
||||
}
|
||||
}
|
||||
return total;
|
||||
return { bytes, unknownItems };
|
||||
}
|
||||
|
||||
function isPendingDownloadItem(item: DownloadItem): boolean {
|
||||
@@ -121,14 +134,14 @@ export function getRemainingDownloadBytes(items: Iterable<DownloadItem>): { byte
|
||||
export function getDownloadQueueStatusMetrics(items: readonly DownloadItem[]): {
|
||||
packageCount: number;
|
||||
pendingItemCount: number;
|
||||
totalBytes: number;
|
||||
total: DownloadByteSummary;
|
||||
remaining: { bytes: number; unknownItems: number };
|
||||
hosterCount: number;
|
||||
} {
|
||||
return {
|
||||
packageCount: new Set(items.map((item) => item.packageId)).size,
|
||||
pendingItemCount: getPendingDownloadItemCount(items),
|
||||
totalBytes: getDownloadQueueTotalBytes(items),
|
||||
total: getDownloadQueueTotalBytes(items),
|
||||
remaining: getRemainingDownloadBytes(items),
|
||||
hosterCount: new Set(items.map((item) => extractHoster(item.url)).filter(Boolean)).size
|
||||
};
|
||||
@@ -147,6 +160,23 @@ export function formatRemainingDownloadTooltip(summary: { bytes: number; unknown
|
||||
return `Noch unbekannte Dateigrößen: ${summary.unknownItems}. Die tatsächliche Restmenge kann höher sein.`;
|
||||
}
|
||||
|
||||
export function formatDownloadEta(
|
||||
remaining: DownloadByteSummary,
|
||||
speedBps: number,
|
||||
running: boolean,
|
||||
paused: boolean
|
||||
): string {
|
||||
if (!running || paused || speedBps <= 0 || remaining.unknownItems > 0 || remaining.bytes <= 0) return "--";
|
||||
const totalSeconds = Math.ceil(remaining.bytes / speedBps);
|
||||
const seconds = totalSeconds % 60;
|
||||
const totalMinutes = Math.floor(totalSeconds / 60);
|
||||
const minutes = totalMinutes % 60;
|
||||
const hours = Math.floor(totalMinutes / 60);
|
||||
return hours > 0
|
||||
? `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`
|
||||
: `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function getDownloadSpeedBps(packageSpeeds: Record<string, number>): number {
|
||||
let total = 0;
|
||||
for (const speed of Object.values(packageSpeeds)) {
|
||||
@@ -159,13 +189,43 @@ function isExtracted(item: DownloadItem): boolean {
|
||||
return item.fullStatus.trim().toLocaleLowerCase("de-DE").startsWith("entpackt");
|
||||
}
|
||||
|
||||
function matchesQuery(value: string | undefined, query: string): boolean {
|
||||
return Boolean(value?.toLocaleLowerCase("de-DE").includes(query));
|
||||
}
|
||||
|
||||
function matchesFilter(item: DownloadItem, filter: DownloadSidebarFilter): boolean {
|
||||
return filter === "all" || classifyDownloadStatus(item.status) === filter;
|
||||
}
|
||||
function matchesQuery(value: string | undefined, query: string): boolean {
|
||||
return Boolean(value?.toLocaleLowerCase("de-DE").includes(query));
|
||||
}
|
||||
|
||||
function isExtractFailure(item: DownloadItem): boolean {
|
||||
const status = item.fullStatus.trim();
|
||||
return /^(?:Entpack(?:-|\s*)Fehler\b|Entpacken\b.*(?:\bFehler\b|\bError\b|fehlgeschlagen)|Extraction\b.*(?:\bError\b|failed))/i.test(status);
|
||||
}
|
||||
|
||||
function classifyDownloadItem(item: DownloadItem, pkg: PackageEntry): DownloadSidebarFilter {
|
||||
if (item.status === "failed" || isExtractFailure(item)) return "failed";
|
||||
if (item.status === "cancelled") return "all";
|
||||
if (isExtracted(item)) return "completed";
|
||||
if ((pkg.status === "extracting" || pkg.status === "integrity_check") && item.status === "completed") return "active";
|
||||
if (item.status === "completed") return "completed";
|
||||
if (pkg.status === "failed") return "failed";
|
||||
if (pkg.status === "paused") return "paused";
|
||||
return classifyDownloadStatus(item.status);
|
||||
}
|
||||
|
||||
function buildDownloadLifecycleCounts(packages: readonly PackageEntry[], items: Record<string, DownloadItem>, hideExtractedItems: boolean): DownloadFilterCounts {
|
||||
const counts: DownloadFilterCounts = { all: 0, active: 0, queued: 0, paused: 0, completed: 0, failed: 0 };
|
||||
for (const pkg of packages) {
|
||||
for (const itemId of pkg.itemIds) {
|
||||
const item = items[itemId];
|
||||
if (!item || (hideExtractedItems && isExtracted(item))) continue;
|
||||
counts.all += 1;
|
||||
const category = classifyDownloadItem(item, pkg);
|
||||
if (category !== "all") counts[category] += 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function matchesFilter(item: DownloadItem, pkg: PackageEntry, filter: DownloadSidebarFilter): boolean {
|
||||
return filter === "all" || classifyDownloadItem(item, pkg) === filter;
|
||||
}
|
||||
|
||||
function matchesProvider(item: DownloadItem, providerFilter: string): boolean {
|
||||
return providerFilter === "all" || item.provider === providerFilter;
|
||||
@@ -194,11 +254,12 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|
||||
const allItems = allPackages.flatMap((entry) => entry.itemIds.map((id) => input.items[id]).filter((item): item is DownloadItem => Boolean(item)));
|
||||
const eligibleItems = input.hideExtractedItems ? allItems.filter((item) => !isExtracted(item)) : allItems;
|
||||
const eligiblePackageCount = new Set(eligibleItems.map((item) => item.packageId)).size;
|
||||
const counts = buildDownloadSidebarCounts(eligibleItems);
|
||||
const counts = buildDownloadLifecycleCounts(allPackages, input.items, input.hideExtractedItems);
|
||||
const providerMap = new Map<string, string>();
|
||||
for (const entry of eligibleItems) {
|
||||
if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider);
|
||||
}
|
||||
if (entry.provider) providerMap.set(entry.provider, entry.providerLabel?.trim() || entry.provider);
|
||||
}
|
||||
const providerFilter = input.providerFilter === "all" || providerMap.has(input.providerFilter) ? input.providerFilter : "all";
|
||||
|
||||
const query = input.query.trim().toLocaleLowerCase("de-DE");
|
||||
const collapsed = new Set(input.collapsedPackageIds);
|
||||
@@ -218,13 +279,13 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|
||||
|| matchesQuery(item.providerAccountLabel, query)
|
||||
|| matchesQuery(item.fullStatus, query)
|
||||
|| matchesQuery(item.lastError, query);
|
||||
return matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter) && (packageMatchesQuery || itemMatchesQuery);
|
||||
});
|
||||
return matchesFilter(item, entry, input.filter) && matchesProvider(item, providerFilter) && (packageMatchesQuery || itemMatchesQuery);
|
||||
});
|
||||
if (matchingItems.length === 0) return [];
|
||||
const visibleItems = packageMatchesQuery && query !== ""
|
||||
? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter))
|
||||
? items.filter((item) => matchesFilter(item, entry, input.filter) && matchesProvider(item, providerFilter))
|
||||
: matchingItems;
|
||||
return [{ package: entry, items: visibleItems, allItems: items, collapsed: collapsed.has(entry.id) }];
|
||||
return [{ package: entry, items: visibleItems, allItems: allPackageItems, collapsed: collapsed.has(entry.id) }];
|
||||
});
|
||||
|
||||
const totalPackageRows = packageRows.length;
|
||||
@@ -247,10 +308,13 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|
||||
? fileRows.length
|
||||
: totalPackageRows;
|
||||
|
||||
return {
|
||||
const sourceEmpty = allItems.length === 0;
|
||||
const presentationEmpty = eligibleItems.length === 0;
|
||||
|
||||
return {
|
||||
displayMode: input.displayMode,
|
||||
filter: input.filter,
|
||||
providerFilter: input.providerFilter,
|
||||
providerFilter,
|
||||
providerOptions: [...providerMap].map(([id, label]) => ({ id, label })).sort((left, right) => left.label.localeCompare(right.label, "de")),
|
||||
query: input.query,
|
||||
counts,
|
||||
@@ -267,7 +331,9 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|
||||
totalMainRowCount,
|
||||
paginationLabel: paginationLabel(mainRowCount, totalMainRowCount),
|
||||
limited: false,
|
||||
empty: eligibleItems.length === 0,
|
||||
filteredEmpty: eligibleItems.length > 0 && mainRowCount === 0
|
||||
sourceEmpty,
|
||||
presentationEmpty,
|
||||
empty: sourceEmpty,
|
||||
filteredEmpty: !sourceEmpty && mainRowCount === 0
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user