diff --git a/CHANGELOG.md b/CHANGELOG.md index 2db1a25..e4e9a31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ All notable changes to Multi-Debrid Downloader are documented in this file. +## [2.0.18] - 2026-08-11 + +### Downloads and status handling + +- Preserved package byte totals, completed-item counts, and extraction progress when finished files are removed immediately from the queue. +- Persisted cleaned package contributions so progress remains stable across application restarts. +- Preserved final history file counts, byte totals, providers, and source URLs after immediate cleanup. +- Reset selected files and their package post-processing state atomically after extraction failures. +- Waited for cancelled extraction work and resume-state cleanup before restarting reset downloads. +- Replaced contradictory unchecked package availability counters with a compact unchecked state. +- Reduced extraction errors, pending extraction phases, password phases, and archive processing to concise visible status labels while retaining full diagnostics in tooltips and logs. +- Cleared stale archive labels before final package state notifications and history updates. +- Removed native whole-row dragging that could create a large drag preview while preserving header column reordering and explicit package move actions. +- Expanded inline package-name editing to the full available name-column width. + +### Settings and account management + +- Added history retention choices for the latest 100 or 250 entries. +- Kept permanent history retention selectable after using a bounded history preset. +- Replaced native settings selectors with smooth, keyboard-accessible dropdowns for consistent opening and closing motion. +- Reworked account creation into a compact searchable service table with separate service and access-type columns. +- Displayed only the credentials required by the selected account type. +- Separated usernames and email addresses in the account overview so verified email data no longer replaces a stored username. +- Kept Mega-Debrid access types explicit as `Mega-Debrid (API)` and `Mega-Debrid (Web)`. + +### Interface fixes + +- Centered the package sidebar heading and added a high-contrast light-blue module accent. +- Positioned context menus and nested menus before they become visible, preventing first-frame jumps at window edges. +- Closed open context menus immediately when another package or file is clicked. +- Kept context menus inside narrow application windows without introducing horizontal overflow. + +### Reliability and testing + +- Added regression coverage for cleanup-safe package progress, persisted progress aggregates, extraction reset state, compact availability, extraction diagnostics, native drag suppression, full-width renaming, animated settings selectors, account identity fields, and viewport-safe context menus. + ## [2.0.17] - 2026-08-10 ### Interface fixes diff --git a/package-lock.json b/package-lock.json index befea0e..2413c10 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "real-debrid-downloader", - "version": "2.0.17", + "version": "2.0.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "real-debrid-downloader", - "version": "2.0.17", + "version": "2.0.18", "license": "MIT", "dependencies": { "adm-zip": "0.6.0", diff --git a/package.json b/package.json index f034d4f..2679e04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "real-debrid-downloader", - "version": "2.0.17", + "version": "2.0.18", "description": "Desktop downloader", "main": "build/main/main/main.js", "author": "Sucukdeluxe", @@ -10,7 +10,7 @@ "dev:renderer": "vite --port 5180 --strictPort", "visual:dev": "vite --config tests/visual/vite.config.mts --host 127.0.0.1 --port 5174 --strictPort", "dev:main:watch": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap --watch", - "dev:electron": "wait-on tcp:5180 file:build/main/main/main.js && cross-env NODE_ENV=development DEV_SERVER_PORT=5180 tsx scripts/run-dev-electron.ts", + "dev:electron": "wait-on tcp:5180 file:build/main/main/main.js && cross-env NODE_ENV=development DEV_SERVER_PORT=5180 tsx scripts/run-dev-electron.ts", "build": "npm run build:main && npm run build:renderer", "build:main": "tsup src/main/main.ts src/preload/preload.ts --out-dir build/main --format cjs --target node20 --external electron --sourcemap", "build:renderer": "vite build", diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 456980d..3e43862 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -1792,9 +1792,13 @@ export class DownloadManager extends EventEmitter { private packagePostProcessAbortControllers = new Map(); - private packageDeferredPostProcessAbortControllers = new Map(); + private packageDeferredPostProcessAbortControllers = new Map(); + + private packageDeferredPostProcessTasks = new Map>>(); - private packageHybridPostProcessControllers = new Map>(); + private packageHybridPostProcessControllers = new Map>(); + + private packageHybridPostProcessTasks = new Map>>(); private packagePostProcessVersions = new Map(); @@ -2561,12 +2565,15 @@ export class DownloadManager extends EventEmitter { return next; } - private abortPackagePostProcessing(packageId: string, reason: string, invalidateDeferred = true): void { - if (invalidateDeferred) { - this.bumpPackagePostProcessVersion(packageId); - } - - const postProcessController = this.packagePostProcessAbortControllers.get(packageId); + private abortPackagePostProcessing(packageId: string, reason: string, invalidateDeferred = true): Promise[] { + const tasks: Promise[] = []; + if (invalidateDeferred) { + this.bumpPackagePostProcessVersion(packageId); + } + + const postProcessTask = this.packagePostProcessTasks.get(packageId); + if (postProcessTask) tasks.push(postProcessTask); + const postProcessController = this.packagePostProcessAbortControllers.get(packageId); if (postProcessController && !postProcessController.signal.aborted) { postProcessController.abort(reason); } @@ -2576,22 +2583,29 @@ export class DownloadManager extends EventEmitter { const deferredController = this.packageDeferredPostProcessAbortControllers.get(packageId); if (deferredController && !deferredController.signal.aborted) { deferredController.abort(reason); - } - this.packageDeferredPostProcessAbortControllers.delete(packageId); + } + this.packageDeferredPostProcessAbortControllers.delete(packageId); + const deferredTasks = this.packageDeferredPostProcessTasks.get(packageId); + if (deferredTasks) tasks.push(...deferredTasks); + this.packageDeferredPostProcessTasks.delete(packageId); - const hybridSet = this.packageHybridPostProcessControllers.get(packageId); + const hybridSet = this.packageHybridPostProcessControllers.get(packageId); if (hybridSet) { for (const controller of hybridSet) { if (!controller.signal.aborted) { controller.abort(reason); } } - this.packageHybridPostProcessControllers.delete(packageId); - } + this.packageHybridPostProcessControllers.delete(packageId); + } + const hybridTasks = this.packageHybridPostProcessTasks.get(packageId); + if (hybridTasks) tasks.push(...hybridTasks); + this.packageHybridPostProcessTasks.delete(packageId); - this.hybridExtractRequeue.delete(packageId); - this.clearHybridArchiveState(packageId); - } + this.hybridExtractRequeue.delete(packageId); + this.clearHybridArchiveState(packageId); + return tasks; + } private isDeferredPostProcessStillCurrent( packageId: string, @@ -2892,8 +2906,10 @@ export class DownloadManager extends EventEmitter { this.speedBytesPerPackage.clear(); this.packagePostProcessTasks.clear(); this.packagePostProcessAbortControllers.clear(); - this.packageDeferredPostProcessAbortControllers.clear(); - this.packageHybridPostProcessControllers.clear(); + this.packageDeferredPostProcessAbortControllers.clear(); + this.packageDeferredPostProcessTasks.clear(); + this.packageHybridPostProcessControllers.clear(); + this.packageHybridPostProcessTasks.clear(); this.hybridExtractRequeue.clear(); this.hybridExtractedPaths.clear(); this.hybridFailedArchives.clear(); @@ -2932,9 +2948,15 @@ export class DownloadManager extends EventEmitter { status: "queued", itemIds: [], cancelled: false, - enabled: true, - priority: "normal", - downloadStartedAt: 0, + enabled: true, + priority: "normal", + cleanedCompletedItemCount: 0, + cleanedExtractedItemCount: 0, + cleanedDownloadedBytes: 0, + cleanedTotalBytes: 0, + cleanedUrls: [], + cleanedProviders: [], + downloadStartedAt: 0, downloadCompletedAt: 0, createdAt: nowMs(), updatedAt: nowMs() @@ -4725,8 +4747,14 @@ export class DownloadManager extends EventEmitter { return removed; } - private hasDeferredPostProcessPending(packageId: string): boolean { - const controller = this.packageDeferredPostProcessAbortControllers.get(packageId); + private hasDeferredPostProcessPending(packageId: string): boolean { + if ((this.packageDeferredPostProcessTasks.get(packageId)?.size || 0) > 0) { + return true; + } + if ((this.packageHybridPostProcessTasks.get(packageId)?.size || 0) > 0) { + return true; + } + const controller = this.packageDeferredPostProcessAbortControllers.get(packageId); if (controller && !controller.signal.aborted) { return true; } @@ -4741,8 +4769,14 @@ export class DownloadManager extends EventEmitter { return false; } - private hasAnyDeferredPostProcessPending(): boolean { - for (const controller of this.packageDeferredPostProcessAbortControllers.values()) { + private hasAnyDeferredPostProcessPending(): boolean { + for (const tasks of this.packageDeferredPostProcessTasks.values()) { + if (tasks.size > 0) return true; + } + for (const tasks of this.packageHybridPostProcessTasks.values()) { + if (tasks.size > 0) return true; + } + for (const controller of this.packageDeferredPostProcessAbortControllers.values()) { if (!controller.signal.aborted) { return true; } @@ -4765,12 +4799,15 @@ export class DownloadManager extends EventEmitter { for (const id of this.packagePostProcessTasks.keys()) { target.add(id); } - for (const [id, controller] of this.packageDeferredPostProcessAbortControllers) { - if (!controller.signal.aborted) { - target.add(id); - } - } - for (const [id, hybridSet] of this.packageHybridPostProcessControllers) { + for (const [id, controller] of this.packageDeferredPostProcessAbortControllers) { + if (!controller.signal.aborted) { + target.add(id); + } + } + for (const [id, tasks] of this.packageHybridPostProcessTasks) { + if (tasks.size > 0) target.add(id); + } + for (const [id, hybridSet] of this.packageHybridPostProcessControllers) { for (const c of hybridSet) { if (!c.signal.aborted) { target.add(id); @@ -5183,7 +5220,7 @@ export class DownloadManager extends EventEmitter { }); } - public resetPackage(packageId: string): void { + public async resetPackage(packageId: string): Promise { const pkg = this.session.packages[packageId]; if (!pkg) return; @@ -5227,20 +5264,25 @@ export class DownloadManager extends EventEmitter { item.updatedAt = nowMs(); } - this.abortPackagePostProcessing(packageId, "reset"); - this.runCompletedPackages.delete(packageId); + const postProcessTasks = this.abortPackagePostProcessing(packageId, "reset"); + this.runCompletedPackages.delete(packageId); - if (pkg.outputDir) { - clearExtractResumeState(pkg.outputDir, packageId).catch(() => {}); - clearExtractResumeState(pkg.outputDir).catch(() => {}); - } - - pkg.status = "queued"; - pkg.cancelled = false; - pkg.enabled = true; - pkg.updatedAt = nowMs(); - this.historyRecordedPackages.delete(packageId); - this.notifiedPackages.delete(packageId); + pkg.status = "queued"; + pkg.cancelled = false; + pkg.enabled = true; + pkg.postProcessLabel = undefined; + pkg.audioStripSummary = undefined; + pkg.cleanedCompletedItemCount = 0; + pkg.cleanedExtractedItemCount = 0; + pkg.cleanedDownloadedBytes = 0; + pkg.cleanedTotalBytes = 0; + pkg.cleanedUrls = []; + pkg.cleanedProviders = []; + pkg.downloadStartedAt = 0; + pkg.downloadCompletedAt = 0; + pkg.updatedAt = nowMs(); + this.historyRecordedPackages.delete(packageId); + this.notifiedPackages.delete(packageId); if (this.session.running) { for (const itemId of itemIds) { @@ -5249,7 +5291,14 @@ export class DownloadManager extends EventEmitter { this.runPackageIds.add(packageId); } - logger.info(`Paket "${pkg.name}" zurückgesetzt (${itemIds.length} Items)`); + await Promise.allSettled(postProcessTasks); + if (pkg.outputDir) { + await Promise.allSettled([ + clearExtractResumeState(pkg.outputDir, packageId), + clearExtractResumeState(pkg.outputDir) + ]); + } + logger.info(`Paket "${pkg.name}" zurückgesetzt (${itemIds.length} Items)`); this.persistSoon(); this.emitState(true); if (this.session.running) { @@ -5257,8 +5306,9 @@ export class DownloadManager extends EventEmitter { } } - public resetItems(itemIds: string[]): void { - const affectedPackageIds = new Set(); + public async resetItems(itemIds: string[]): Promise { + const affectedPackageIds = new Set(); + const postProcessTasks = new Set>(); for (const itemId of itemIds) { const item = this.session.items[itemId]; if (!item) continue; @@ -5305,23 +5355,32 @@ export class DownloadManager extends EventEmitter { } for (const pkgId of affectedPackageIds) { - this.abortPackagePostProcessing(pkgId, "reset"); + for (const task of this.abortPackagePostProcessing(pkgId, "reset")) postProcessTasks.add(task); this.runCompletedPackages.delete(pkgId); this.historyRecordedPackages.delete(pkgId); this.notifiedPackages.delete(pkgId); - const pkg = this.session.packages[pkgId]; - if (pkg && (pkg.status === "completed" || pkg.status === "failed" || pkg.status === "cancelled")) { - pkg.status = "queued"; - pkg.cancelled = false; - pkg.updatedAt = nowMs(); - } + const pkg = this.session.packages[pkgId]; + if (pkg) { + pkg.cancelled = false; + pkg.postProcessLabel = undefined; + pkg.audioStripSummary = undefined; + pkg.downloadCompletedAt = 0; + this.refreshPackageStatus(pkg); + pkg.updatedAt = nowMs(); + } if (this.session.running) { this.runPackageIds.add(pkgId); } } - logger.info(`${itemIds.length} Item(s) zurückgesetzt`); + await Promise.allSettled([...postProcessTasks]); + await Promise.allSettled([...affectedPackageIds].flatMap((pkgId) => { + const pkg = this.session.packages[pkgId]; + return pkg?.outputDir ? [clearExtractResumeState(pkg.outputDir, pkgId)] : []; + })); + + logger.info(`${itemIds.length} Item(s) zurückgesetzt`); this.persistSoon(); this.emitState(true); if (this.session.running) { @@ -7761,27 +7820,32 @@ export class DownloadManager extends EventEmitter { if (!this.onHistoryEntryCallback || this.historyRecordedPackages.has(packageId)) { return; } - const completedItems = items.filter(item => item.status === "completed"); - if (completedItems.length === 0) { - return; - } - this.historyRecordedPackages.add(packageId); - const totalBytes = completedItems.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0); - const durationSeconds = this.getPackageHistoryDurationSeconds(pkg); - const providers = new Set(completedItems.map(item => item.provider).filter(Boolean)); - const provider = providers.size === 1 ? [...providers][0] : null; + const completedItems = items.filter(item => item.status === "completed"); + const cleanedCount = Math.max(0, Number(pkg.cleanedCompletedItemCount || 0)); + if (completedItems.length + cleanedCount === 0) { + return; + } + this.historyRecordedPackages.add(packageId); + const totalBytes = Math.max(0, Number(pkg.cleanedDownloadedBytes || 0)) + + completedItems.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0); + const durationSeconds = this.getPackageHistoryDurationSeconds(pkg); + const providers = new Set([ + ...(pkg.cleanedProviders || []), + ...completedItems.map(item => item.provider).filter(Boolean) + ]); + const provider = providers.size === 1 ? [...providers][0] : null; const entry: HistoryEntry = { id: generateHistoryId(), name: pkg.name, totalBytes, downloadedBytes: totalBytes, - fileCount: completedItems.length, + fileCount: cleanedCount + completedItems.length, provider, completedAt: nowMs(), durationSeconds, status: "completed", outputDir: pkg.outputDir, - urls: completedItems.map(item => item.url).filter(Boolean), + urls: [...new Set([...(pkg.cleanedUrls || []), ...completedItems.map(item => item.url).filter(Boolean)])], }; this.onHistoryEntryCallback(entry); } @@ -7795,13 +7859,18 @@ export class DownloadManager extends EventEmitter { }); } if (pkg && this.onHistoryEntryCallback && reason === "deleted" && !this.historyRecordedPackages.has(packageId)) { - const allItems = itemIds.map(id => this.session.items[id]).filter(Boolean) as DownloadItem[]; - const completedItems = allItems.filter(item => item.status === "completed"); - const completedCount = completedItems.length; - if (completedCount > 0) { - const totalBytes = completedItems.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0); - const durationSeconds = this.getPackageHistoryDurationSeconds(pkg); - const providers = new Set(completedItems.map(item => item.provider).filter(Boolean)); + const allItems = itemIds.map(id => this.session.items[id]).filter(Boolean) as DownloadItem[]; + const completedItems = allItems.filter(item => item.status === "completed"); + const cleanedCount = Math.max(0, Number(pkg.cleanedCompletedItemCount || 0)); + const completedCount = cleanedCount + completedItems.length; + if (completedCount > 0) { + const totalBytes = Math.max(0, Number(pkg.cleanedDownloadedBytes || 0)) + + completedItems.reduce((sum, item) => sum + (item.downloadedBytes || 0), 0); + const durationSeconds = this.getPackageHistoryDurationSeconds(pkg); + const providers = new Set([ + ...(pkg.cleanedProviders || []), + ...completedItems.map(item => item.provider).filter(Boolean) + ]); const provider = providers.size === 1 ? [...providers][0] : null; const entry: HistoryEntry = { id: generateHistoryId(), @@ -7814,7 +7883,7 @@ export class DownloadManager extends EventEmitter { durationSeconds, status: "deleted", outputDir: pkg.outputDir, - urls: completedItems.map(item => item.url).filter(Boolean), + urls: [...new Set([...(pkg.cleanedUrls || []), ...completedItems.map(item => item.url).filter(Boolean)])], }; this.onHistoryEntryCallback(entry); } @@ -11510,8 +11579,8 @@ export class DownloadManager extends EventEmitter { } }, onProgress: (progress) => { - if (progress.phase === "preparing") { - pkg.postProcessLabel = progress.archiveName || "Vorbereiten..."; + if (progress.phase === "preparing") { + pkg.postProcessLabel = "Entpacken - Ausstehend"; this.emitState(); return; } @@ -11602,10 +11671,10 @@ export class DownloadManager extends EventEmitter { } } - const activeArchive = !archiveFinished && Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; - const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); - if (progress.passwordFound) { - pkg.postProcessLabel = `Passwort gefunden · ${progress.archiveName || ""}`; + const activeArchive = !archiveFinished && Number(progress.archivePercent ?? 0) > 0 ? 1 : 0; + const currentDisplay = Math.max(0, Math.min(progress.total, progress.current + activeArchive)); + if (progress.passwordFound) { + pkg.postProcessLabel = "Passwort gefunden"; } else if (progress.passwordAttempt && progress.passwordTotal && progress.passwordTotal > 1) { const pwPct = Math.round((progress.passwordAttempt / progress.passwordTotal) * 100); pkg.postProcessLabel = `Passwort knacken: ${pwPct}%`; @@ -11671,8 +11740,9 @@ export class DownloadManager extends EventEmitter { } hybridSet.add(hybridController); const hybridShouldAbort = (): boolean => hybridController.signal.aborted || this.session.packages[packageId] !== pkg; - void (async () => { - try { + const hybridHandle: { task?: Promise } = {}; + const hybridTask = (async () => { + try { await this.chainPackageFileOp(pkg.id, async () => { await this.autoRenameExtractedVideoFilesImpl(pkg.extractDir, pkg, hybridShouldAbort); await this.keepGermanAudioOnlyImpl(pkg.extractDir, pkg, hybridShouldAbort, hybridController.signal); @@ -11682,15 +11752,24 @@ export class DownloadManager extends EventEmitter { logger.warn(`Hybrid Post-Extract (Rename+Collect) Fehler: pkg=${pkg.name}, reason=${compactErrorText(err)}`); } finally { const set = this.packageHybridPostProcessControllers.get(packageId); - if (set) { + if (set) { set.delete(hybridController); if (set.size === 0) { this.packageHybridPostProcessControllers.delete(packageId); - } - } - } - })(); - } + } + } + const tasks = this.packageHybridPostProcessTasks.get(packageId); + if (hybridHandle.task) tasks?.delete(hybridHandle.task); + if (tasks?.size === 0) { + this.packageHybridPostProcessTasks.delete(packageId); + } + } + })(); + hybridHandle.task = hybridTask; + const hybridTasks = this.packageHybridPostProcessTasks.get(packageId) || new Set>(); + hybridTasks.add(hybridTask); + this.packageHybridPostProcessTasks.set(packageId, hybridTasks); + } if (result.failed > 0) { logger.warn(`Hybrid-Extract: ${result.failed} Archive fehlgeschlagen, werden erst nach echter Aenderung oder manuellem Retry erneut versucht`); } @@ -12028,10 +12107,10 @@ export class DownloadManager extends EventEmitter { failure.archiveName, failure.errorText || failure.jvmFailureReason || "Entpacken fehlgeschlagen" ); - }, - onProgress: (progress) => { - if (progress.phase === "preparing") { - pkg.postProcessLabel = progress.archiveName || "Vorbereiten..."; + }, + onProgress: (progress) => { + if (progress.phase === "preparing") { + pkg.postProcessLabel = "Entpacken - Ausstehend"; this.emitState(); return; } @@ -12251,16 +12330,17 @@ export class DownloadManager extends EventEmitter { pkg.status = "completed"; } - if (pkg.status === "completed") { + pkg.postProcessLabel = undefined; + pkg.updatedAt = nowMs(); + + if (pkg.status === "completed") { this.notifyPackageOutcome(pkg, "completed", `${success} Datei(en)${extractedCount > 0 ? `, ${extractedCount} entpackt` : ""}`); } else if (pkg.status === "failed") { this.notifyPackageOutcome(pkg, "failed", `${failed} von ${success + failed + cancelled} Datei(en) fehlgeschlagen`); } - this.emitState(); - - if (pkg.status === "completed" || (pkg.status === "failed" && success > 0)) { - this.recordPackageHistory(packageId, pkg, items); + if (pkg.status === "completed" || (pkg.status === "failed" && success > 0)) { + this.recordPackageHistory(packageId, pkg, items); } if (this.runPackageIds.has(packageId)) { @@ -12269,9 +12349,8 @@ export class DownloadManager extends EventEmitter { } else { this.runCompletedPackages.delete(packageId); } - } - pkg.postProcessLabel = undefined; - pkg.updatedAt = nowMs(); + } + this.emitState(); logger.info(`Post-Processing Ende: pkg=${pkg.name}, status=${pkg.status} (deferred work wird im Hintergrund ausgeführt)`); this.logPackageForPackage(pkg, "INFO", "Post-Processing Ende", { status: pkg.status, @@ -12284,7 +12363,29 @@ export class DownloadManager extends EventEmitter { void this.runDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount); } - private async runDeferredPostExtraction( + private runDeferredPostExtraction( + packageId: string, + pkg: PackageEntry, + success: number, + failed: number, + alreadyMarkedExtracted: boolean, + extractedCount: number + ): Promise { + const task = this.executeDeferredPostExtraction(packageId, pkg, success, failed, alreadyMarkedExtracted, extractedCount) + .finally(() => { + const tasks = this.packageDeferredPostProcessTasks.get(packageId); + tasks?.delete(task); + if (tasks?.size === 0) { + this.packageDeferredPostProcessTasks.delete(packageId); + } + }); + const tasks = this.packageDeferredPostProcessTasks.get(packageId) || new Set>(); + tasks.add(task); + this.packageDeferredPostProcessTasks.set(packageId, tasks); + return task; + } + + private async executeDeferredPostExtraction( packageId: string, pkg: PackageEntry, success: number, @@ -12507,7 +12608,7 @@ export class DownloadManager extends EventEmitter { this.removePackageFromSession(packageId, [...pkg.itemIds], "completed"); } - private applyCompletedCleanupPolicy( + private applyCompletedCleanupPolicy( packageId: string, itemId: string, options?: { ignoreDeferred?: boolean } @@ -12531,13 +12632,24 @@ export class DownloadManager extends EventEmitter { if (!item || item.status !== "completed") { return; } - if (this.settings.autoExtract) { - const extracted = isExtractedLabel(item.fullStatus || ""); - if (!extracted) { - return; - } - } - pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId); + if (this.settings.autoExtract) { + const extracted = isExtractedLabel(item.fullStatus || ""); + if (!extracted) { + return; + } + } + pkg.cleanedCompletedItemCount = Math.max(0, Number(pkg.cleanedCompletedItemCount || 0)) + 1; + if (isExtractedLabel(item.fullStatus || "")) { + pkg.cleanedExtractedItemCount = Math.max(0, Number(pkg.cleanedExtractedItemCount || 0)) + 1; + } + pkg.cleanedDownloadedBytes = Math.max(0, Number(pkg.cleanedDownloadedBytes || 0)) + Math.max(0, item.downloadedBytes || 0); + pkg.cleanedTotalBytes = Math.max(0, Number(pkg.cleanedTotalBytes || 0)) + Math.max(0, item.totalBytes || item.downloadedBytes || 0); + pkg.cleanedUrls = [...new Set([...(pkg.cleanedUrls || []), item.url].filter(Boolean))]; + pkg.cleanedProviders = item.provider + ? [...new Set([...(pkg.cleanedProviders || []), item.provider])] + : [...(pkg.cleanedProviders || [])]; + pkg.updatedAt = nowMs(); + pkg.itemIds = pkg.itemIds.filter((id) => id !== itemId); this.releaseTargetPath(itemId); this.dropItemContribution(itemId); delete this.session.items[itemId]; diff --git a/src/main/storage.ts b/src/main/storage.ts index 3dd91b7..7e6523c 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -782,9 +782,19 @@ export function normalizeLoadedSession(raw: unknown): SessionState { .filter((value) => value.length > 0), cancelled: Boolean(pkg.cancelled), enabled: pkg.enabled === undefined ? true : Boolean(pkg.enabled), - priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal", - audioStripSummary: normalizeAudioStripSummary(pkg.audioStripSummary), - downloadStartedAt: clampNumber(pkg.downloadStartedAt, 0, 0, Number.MAX_SAFE_INTEGER), + priority: VALID_PACKAGE_PRIORITIES.has(asText(pkg.priority)) ? asText(pkg.priority) as PackagePriority : "normal", + audioStripSummary: normalizeAudioStripSummary(pkg.audioStripSummary), + cleanedCompletedItemCount: clampNumber(pkg.cleanedCompletedItemCount, 0, 0, 1_000_000), + cleanedExtractedItemCount: clampNumber(pkg.cleanedExtractedItemCount, 0, 0, 1_000_000), + cleanedDownloadedBytes: clampNumber(pkg.cleanedDownloadedBytes, 0, 0, 10_000_000_000_000), + cleanedTotalBytes: clampNumber(pkg.cleanedTotalBytes, 0, 0, 10_000_000_000_000), + cleanedUrls: Array.isArray(pkg.cleanedUrls) + ? [...new Set(pkg.cleanedUrls.map((value) => asText(value)).filter(Boolean))].slice(0, 1_000_000) + : [], + cleanedProviders: Array.isArray(pkg.cleanedProviders) + ? [...new Set(pkg.cleanedProviders.map((value) => asText(value) as DebridProvider).filter((value) => VALID_ITEM_PROVIDERS.has(value)))] + : [], + downloadStartedAt: clampNumber(pkg.downloadStartedAt, 0, 0, Number.MAX_SAFE_INTEGER), downloadCompletedAt: clampNumber(pkg.downloadCompletedAt, 0, 0, Number.MAX_SAFE_INTEGER), createdAt: clampNumber(pkg.createdAt, now, 0, Number.MAX_SAFE_INTEGER), updatedAt: clampNumber(pkg.updatedAt, now, 0, Number.MAX_SAFE_INTEGER) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 4d35df9..6fbcd68 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -36,7 +36,7 @@ import { getProviderDailyUsageBytes, getProviderUsageDayKey } from "../shared/provider-daily-limits"; -import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order"; +import { sortPackageOrderByName, sortPackagesForDisplay } from "./package-order"; import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection"; import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui"; import type { AccountModeFilter } from "./account-ui"; @@ -107,6 +107,7 @@ import { buildSettingsFormViewModel, buildTargetedAccountCheck, projectAccountRows, + resolveHistoryRetentionSelection, sortAccountRows, type AccountAddOption, type AccountRowSource, @@ -1765,7 +1766,6 @@ export function App(): ReactElement { const serverPackageOrderRef = useRef([]); const pendingPackageOrderRef = useRef(null); const pendingPackageOrderAtRef = useRef(0); - const draggedPackageIdRef = useRef(null); const [collapsedPackages, setCollapsedPackages] = useState>({}); const [downloadSearch, setDownloadSearch] = useState(""); const [downloadDisplayMode, setDownloadDisplayMode] = useState("packages"); @@ -3871,34 +3871,6 @@ export function App(): ReactElement { }); }, [showToast]); - const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => { - const currentOrder = packageOrderRef.current; - const nextOrder = reorderPackageOrderByDrop(currentOrder, draggedPackageId, targetPackageId); - const unchanged = nextOrder.length === currentOrder.length - && nextOrder.every((id, index) => id === currentOrder[index]); - if (unchanged) { - return; - } - setDownloadsSortDescending(false); - pendingPackageOrderRef.current = [...nextOrder]; - pendingPackageOrderAtRef.current = Date.now(); - packageOrderRef.current = [...nextOrder]; - setSnapshot((prev) => { - if (!prev) return prev; - return { ...prev, session: { ...prev.session, packageOrder: [...nextOrder] } }; - }); - void window.rd.reorderPackages(nextOrder).catch((error) => { - pendingPackageOrderRef.current = null; - pendingPackageOrderAtRef.current = 0; - packageOrderRef.current = serverPackageOrderRef.current; - setSnapshot((prev) => { - if (!prev) return prev; - return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } }; - }); - showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); - }); - }, [showToast]); - const addCollectorTab = (): void => { const id = `tab-${nextCollectorId++}`; setCollectorTabs((prev) => { @@ -3992,23 +3964,6 @@ export function App(): ReactElement { setCollectorError(""); }; - const onPackageDragStart = useCallback((packageId: string) => { - draggedPackageIdRef.current = packageId; - }, []); - - const onPackageDrop = useCallback((targetPackageId: string) => { - const draggedPackageId = draggedPackageIdRef.current; - draggedPackageIdRef.current = null; - if (!draggedPackageId || draggedPackageId === targetPackageId) { - return; - } - reorderPackagesByDrop(draggedPackageId, targetPackageId); - }, [reorderPackagesByDrop]); - - const onPackageDragEnd = useCallback(() => { - draggedPackageIdRef.current = null; - }, []); - const onPackageStartEdit = useCallback((packageId: string, packageName: string): void => { setEditingPackageId(packageId); setEditingName(packageName); @@ -5026,9 +4981,6 @@ export function App(): ReactElement { }); }, onShowAllPackages: () => setShowAllPackages(true), - onPackageDragStart, - onPackageDrop, - onPackageDragEnd, onSetVisibleSelection: (ids, selected) => { setSelectedIds((current) => { const next = new Set(current); @@ -5358,6 +5310,12 @@ export function App(): ReactElement { applyTheme(next); return; } + if (fieldId === "historyRetentionMode" && typeof value === "string") { + const next = resolveHistoryRetentionSelection(settingsDraft.historyRetentionMode, settingsDraft.historyMaxEntries, value); + setText("historyRetentionMode", next.historyRetentionMode); + setNum("historyMaxEntries", next.historyMaxEntries); + return; + } if (typeof value === "boolean") { setBool(fieldId as keyof AppSettings, value); return; @@ -5579,8 +5537,7 @@ export function App(): ReactElement { className={`md-runtime-root${dragOver ? " drag-over" : ""}${tab === "settings" ? " settings-active" : ""}`} onDragEnter={(event) => { event.preventDefault(); - if (draggedPackageIdRef.current) { return; } - const hasFiles = event.dataTransfer.types.includes("Files"); + const hasFiles = event.dataTransfer.types.includes("Files"); const hasUri = event.dataTransfer.types.includes("text/uri-list"); if (!hasFiles && !hasUri) { return; } dragDepthRef.current += 1; @@ -5593,8 +5550,7 @@ export function App(): ReactElement { e.preventDefault(); }} onDragLeave={() => { - if (draggedPackageIdRef.current) { return; } - dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1); if (dragDepthRef.current === 0 && dragOverRef.current) { dragOverRef.current = false; setDragOver(false); diff --git a/src/renderer/download-format.ts b/src/renderer/download-format.ts index 4fe1265..caafbf1 100644 --- a/src/renderer/download-format.ts +++ b/src/renderer/download-format.ts @@ -30,7 +30,7 @@ export function compactProviderLabels(labels: string[]): string { } export function normalizeDownloadServiceLabel(label: string): string { - return [...new Set(label.split(",").map((entry) => entry.trim().replace(/^(Mega-Debrid)\s+(Web|API)(?:\s+\([^)]*\))?$/i, "$1 $2")).filter(Boolean))].join(", "); + return [...new Set(label.split(",").map((entry) => entry.trim().replace(/^(Mega-Debrid)\s+(Web|API)(?:\s+\([^)]*\))?$/i, "$1 ($2)")).filter(Boolean))].join(", "); } export function compactDownloadServiceLabel(label: string): string { diff --git a/src/renderer/i18n.ts b/src/renderer/i18n.ts index 4526bc5..4889abd 100644 --- a/src/renderer/i18n.ts +++ b/src/renderer/i18n.ts @@ -11,7 +11,7 @@ const pairs = [ ["Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.", "Storage location, download behavior, history, interface and notifications."], ["Download-Ordner", "Download folder"], ["Paketname (optional)", "Package name (optional)"], ["Max. gleichzeitige Downloads", "Max. concurrent downloads"], ["Automatische Wiederholungen", "Automatic retries"], ["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."], - ["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Dauerhaft", "Permanent"], + ["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Nur letzte 100 Einträge", "Last 100 entries only"], ["Nur letzte 250 Einträge", "Last 250 entries only"], ["Dauerhaft", "Permanent"], ["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"], ["Nach Fortschritt sortieren", "Sort by progress"], ["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"], ["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"], @@ -29,7 +29,7 @@ const pairs = [ ["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"], ["Ü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"], ["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"], ["Aktiviert", "Enabled"], ["Aktionen", "Actions"], ["Deaktiviert", "Disabled"], ["Premium aktiv", "Premium active"], ["API-Key aktiv", "API key active"], ["API-Account", "API account"], ["API-Key", "API key"], ["Ungültiger API-Key (nicht autorisiert)", "Invalid API key (not authorized)"], ["Free Account", "Free account"], ["Unbeschränkt", "Unlimited"], ["Keine Accounts eingerichtet", "No accounts configured"], @@ -75,7 +75,7 @@ const pairs = [ ["Füge einen Account hinzu, um Downloads über einen Anbieter zu starten.", "Add an account to start downloads through a provider."], ["Noch keine Accounts", "No accounts yet"], ["Keine Provider konfiguriert.", "No providers configured."], ["Keine eigenen Zuordnungen.", "No custom assignments."], ["Hoster-Routing hinzufügen", "Add hoster routing"], ["Hoster hinzufügen…", "Add hoster…"], ["Eigener Hoster…", "Custom hoster…"], ["Noch keine Rotations-Ereignisse.", "No rotation events yet."], ["Prüfen und speichern", "Check and save"], ["Wähle einen Dienst und trage die passenden Zugangsdaten ein.", "Choose a service and enter the matching credentials."], ["Accounts durchsuchen", "Search accounts"], - ["Dienst oder Zugangstyp suchen", "Search service or access type"], ["Account-Typ filtern", "Filter account type"], ["Verfügbare Account-Typen", "Available account types"], ["Keine passenden Account-Typen.", "No matching account types."], + ["Dienst / Zugangstyp", "Service / access type"], ["Dienst", "Service"], ["Typ/Funktion", "Type/function"], ["Dienst oder Zugangstyp suchen", "Search service or access type"], ["Account-Typ filtern", "Filter account type"], ["Verfügbare Account-Typen", "Available account types"], ["Keine passenden Account-Typen.", "No matching account types."], ["Prüfen", "Check"], ["Bearbeite ausschließlich den ausgewählten Account.", "Edit only the selected account."], ["Account bearbeiten", "Edit account"], ["Account aktiviert", "Account enabled"], ["Immer erste Tonspur", "Always first audio track"], ["Pro Download", "Per download"], ["Keine Archive löschen", "Do not delete archives"], ["Archive in Papierkorb", "Move archives to recycle bin"], ["Archive löschen", "Delete archives"], ["Accounts und Verwendungsregeln.", "Accounts and usage rules."], ["Premium Account", "Premium account"], ["Zugang ungültig", "Invalid access"], ["Prüft…", "Checking…"], ["Geschützter Zugang", "Protected access"], @@ -238,6 +238,8 @@ function translateDynamic(value: string, language: AppLanguage): string { if (assignment) return `Remove ${assignment[1]} assignment`; const providerFor = value.match(/^Provider für (.+)$/); if (providerFor) return `Provider for ${providerFor[1]}`; + const credentialsFor = value.match(/^Zugangsdaten für (.+)$/); + if (credentialsFor) return `Credentials for ${credentialsFor[1]}`; const move = value.match(/^(.+) nach (oben|unten)$/); if (move) return `Move ${move[1]} ${move[2] === "oben" ? "up" : "down"}`; const audio = value.match(/^Tonspur: (.+)$/); @@ -385,6 +387,8 @@ function translateDynamic(value: string, language: AppLanguage): string { if (extracting) return `Entpacken ${extracting[1]}`; const checkedUntil = value.match(/^Account checked — (.+) until (.+)$/); if (checkedUntil) return `Account geprüft — ${checkedUntil[1]} bis ${checkedUntil[2]}`; + const credentialsFor = value.match(/^Credentials for (.+)$/); + if (credentialsFor) return `Zugangsdaten für ${credentialsFor[1]}`; const checked = value.match(/^Account checked — (.+)$/); if (checked) return `Account geprüft — ${checked[1]}`; const invalid = value.match(/^Invalid account — (.+)$/); diff --git a/src/renderer/shell/shell.css b/src/renderer/shell/shell.css index c1659f5..db1b44b 100644 --- a/src/renderer/shell/shell.css +++ b/src/renderer/shell/shell.css @@ -773,11 +773,7 @@ background: var(--ui-border); } -.md-context-menu .ctx-menu-sub.is-keyboard-open > .ctx-menu-sub-items { - display: block; -} - -.md-toast { +.md-toast { right: 20px; bottom: 84px; z-index: var(--md-layer-toast); diff --git a/src/renderer/styles.css b/src/renderer/styles.css index 2d27339..714288b 100644 --- a/src/renderer/styles.css +++ b/src/renderer/styles.css @@ -2710,9 +2710,9 @@ td { content: ""; } -.ctx-menu-sub-items { - display: none; - position: absolute; +.ctx-menu-sub-items { + display: block; + position: absolute; left: 100%; top: 0; min-width: 120px; @@ -2720,13 +2720,19 @@ td { border: 1px solid var(--border); border-radius: 6px; padding: 4px 0; - box-shadow: 0 4px 12px rgba(0,0,0,.3); - z-index: 1001; -} - -.ctx-menu-sub:hover .ctx-menu-sub-items { - display: block; -} + box-shadow: 0 4px 12px rgba(0,0,0,.3); + z-index: 1001; + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.ctx-menu-sub:hover > .ctx-menu-sub-items.is-positioned, +.ctx-menu-sub.is-keyboard-open > .ctx-menu-sub-items.is-positioned { + opacity: 1; + visibility: visible; + pointer-events: auto; +} .ctx-menu-active { color: var(--accent) !important; @@ -3049,7 +3055,7 @@ td { z-index: 50; } -.ctx-menu { +.ctx-menu { position: fixed; z-index: 100; min-width: 200px; @@ -3057,8 +3063,13 @@ td { border: 1px solid var(--border); border-radius: 8px; padding: 4px 0; - box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); -} + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); +} + +.ctx-menu:not(.is-positioned) { + visibility: hidden; + opacity: 0; +} .ctx-menu-item { display: block; diff --git a/src/renderer/ui/ContextMenu.tsx b/src/renderer/ui/ContextMenu.tsx index baea4b0..66f65d2 100644 --- a/src/renderer/ui/ContextMenu.tsx +++ b/src/renderer/ui/ContextMenu.tsx @@ -1,323 +1,327 @@ -import { - Children, - cloneElement, - forwardRef, - isValidElement, - useEffect, - useImperativeHandle, - useLayoutEffect, - useRef, - useState, - type KeyboardEvent, - type ReactElement, - type ReactNode, - type RefObject -} from "react"; -import { restoreFocus } from "./focus"; - -const useImmediateEffect = typeof document === "undefined" ? useEffect : useLayoutEffect; - -export interface ContextMenuProps { - open: boolean; - x: number; - y: number; - onClose: () => void; - children: ReactNode; - ariaLabel?: string; - className?: string; - ignoreOutsideRefs?: Array>; -} - -export type ContextMenuKeyboardAction = - | { type: "focus"; index: number } - | { type: "activate"; index: number } - | { type: "close" }; - -export type ContextMenuSubmenuKeyboardAction = "open" | "close"; - -export function clampContextMenuPosition( - x: number, - y: number, - width: number, - height: number, - viewportWidth: number, - viewportHeight: number -): { x: number; y: number } { - return { - x: Math.max(0, Math.min(x, Math.max(0, viewportWidth - width))), - y: Math.max(0, Math.min(y, Math.max(0, viewportHeight - height))) - }; -} - -export function getContextSubmenuPosition( - trigger: { left: number; right: number; top: number }, - submenu: { width: number; height: number }, - viewport: { width: number; height: number } -): { x: number; y: number } { - const opensRight = trigger.right + submenu.width <= viewport.width || trigger.left - submenu.width < 0; - return clampContextMenuPosition( - opensRight ? trigger.right : trigger.left - submenu.width, - trigger.top, - submenu.width, - submenu.height, - viewport.width, - viewport.height - ); -} - -export function getContextMenuKeyboardAction( - key: string, - currentIndex: number, - enabled: boolean[] -): ContextMenuKeyboardAction | null { - const indexes = enabled.flatMap((value, index) => value ? [index] : []); - if (key === "Escape") { - return { type: "close" }; - } - if (indexes.length === 0) { - return null; - } - if (key === "Enter" || key === " ") { - return { type: "activate", index: enabled[currentIndex] ? currentIndex : indexes[0] }; - } - if (key === "Home") { - return { type: "focus", index: indexes[0] }; - } - if (key === "End") { - return { type: "focus", index: indexes[indexes.length - 1] }; - } - if (key !== "ArrowDown" && key !== "ArrowUp") { - return null; - } - const enabledPosition = indexes.indexOf(currentIndex); - if (enabledPosition < 0) { - return { type: "focus", index: key === "ArrowDown" ? indexes[0] : indexes[indexes.length - 1] }; - } - const direction = key === "ArrowDown" ? 1 : -1; - const nextPosition = (enabledPosition + direction + indexes.length) % indexes.length; - return { type: "focus", index: indexes[nextPosition] }; -} - -export function getContextMenuSubmenuKeyboardAction( - key: string, - hasSubmenu: boolean, - insideSubmenu: boolean -): ContextMenuSubmenuKeyboardAction | null { - if (hasSubmenu && (key === "Enter" || key === "ArrowRight")) { - return "open"; - } - if (insideSubmenu && (key === "ArrowLeft" || key === "Escape")) { - return "close"; - } - return null; -} - -function applyMenuItemSemantics(node: ReactNode): ReactNode { - return Children.map(node, (child) => { - if (!isValidElement(child)) { - return child; - } - const element = child as ReactElement<{ - children?: ReactNode; - disabled?: boolean; - role?: string; - tabIndex?: number; - }>; - if (typeof element.type === "string" && element.type === "button") { - return cloneElement(element, { role: "menuitem", tabIndex: -1 }); - } - if (element.props.children === undefined) { - return element; - } - return cloneElement(element, { children: applyMenuItemSemantics(element.props.children) }); - }); -} - -function getMenuItems(menu: HTMLElement | null): HTMLElement[] { - return Array.from(menu?.querySelectorAll("[role='menuitem']") ?? []).filter((item) => { - if (item.matches(":disabled") || item.getAttribute("aria-disabled") === "true") { - return false; - } - return item.getClientRects().length > 0; - }); -} - -function getTopLevelMenuItems(menu: HTMLElement | null): HTMLElement[] { - return getMenuItems(menu).filter((item) => !item.closest(".ctx-menu-sub-items")); -} - -function getSubmenuParts(item: HTMLElement | null): { - container: HTMLElement; - trigger: HTMLElement; - items: HTMLElement; -} | null { - const container = item?.closest(".ctx-menu-sub") ?? null; - if (!container) { - return null; - } - const trigger = Array.from(container.children).find((child) => child.matches("[role='menuitem']")); - const items = Array.from(container.children).find((child) => child.matches(".ctx-menu-sub-items")); - if (!(trigger instanceof HTMLElement) || !(items instanceof HTMLElement)) { - return null; - } - return { container, trigger, items }; -} - -function openSubmenu(parts: ReturnType): void { - if (!parts) { - return; - } - parts.container.classList.add("is-keyboard-open"); - parts.trigger.setAttribute("aria-expanded", "true"); - positionSubmenu(parts); - getMenuItems(parts.items)[0]?.focus(); -} - -function positionSubmenu(parts: NonNullable>): void { - const triggerRect = parts.trigger.getBoundingClientRect(); - const submenuRect = parts.items.getBoundingClientRect(); - const position = getContextSubmenuPosition( - triggerRect, - submenuRect, - { width: window.innerWidth, height: window.innerHeight } - ); +import { + Children, + cloneElement, + forwardRef, + isValidElement, + useEffect, + useImperativeHandle, + useLayoutEffect, + useRef, + useState, + type KeyboardEvent, + type ReactElement, + type ReactNode, + type RefObject +} from "react"; +import { restoreFocus } from "./focus"; + +const useImmediateEffect = typeof document === "undefined" ? useEffect : useLayoutEffect; + +export interface ContextMenuProps { + open: boolean; + x: number; + y: number; + onClose: () => void; + children: ReactNode; + ariaLabel?: string; + className?: string; + ignoreOutsideRefs?: Array>; +} + +export type ContextMenuKeyboardAction = + | { type: "focus"; index: number } + | { type: "activate"; index: number } + | { type: "close" }; + +export type ContextMenuSubmenuKeyboardAction = "open" | "close"; + +export function clampContextMenuPosition( + x: number, + y: number, + width: number, + height: number, + viewportWidth: number, + viewportHeight: number +): { x: number; y: number } { + return { + x: Math.max(0, Math.min(x, Math.max(0, viewportWidth - width))), + y: Math.max(0, Math.min(y, Math.max(0, viewportHeight - height))) + }; +} + +export function getContextSubmenuPosition( + trigger: { left: number; right: number; top: number }, + submenu: { width: number; height: number }, + viewport: { width: number; height: number } +): { x: number; y: number } { + const opensRight = trigger.right + submenu.width <= viewport.width || trigger.left - submenu.width < 0; + return clampContextMenuPosition( + opensRight ? trigger.right : trigger.left - submenu.width, + trigger.top, + submenu.width, + submenu.height, + viewport.width, + viewport.height + ); +} + +export function getContextMenuKeyboardAction( + key: string, + currentIndex: number, + enabled: boolean[] +): ContextMenuKeyboardAction | null { + const indexes = enabled.flatMap((value, index) => value ? [index] : []); + if (key === "Escape") { + return { type: "close" }; + } + if (indexes.length === 0) { + return null; + } + if (key === "Enter" || key === " ") { + return { type: "activate", index: enabled[currentIndex] ? currentIndex : indexes[0] }; + } + if (key === "Home") { + return { type: "focus", index: indexes[0] }; + } + if (key === "End") { + return { type: "focus", index: indexes[indexes.length - 1] }; + } + if (key !== "ArrowDown" && key !== "ArrowUp") { + return null; + } + const enabledPosition = indexes.indexOf(currentIndex); + if (enabledPosition < 0) { + return { type: "focus", index: key === "ArrowDown" ? indexes[0] : indexes[indexes.length - 1] }; + } + const direction = key === "ArrowDown" ? 1 : -1; + const nextPosition = (enabledPosition + direction + indexes.length) % indexes.length; + return { type: "focus", index: indexes[nextPosition] }; +} + +export function getContextMenuSubmenuKeyboardAction( + key: string, + hasSubmenu: boolean, + insideSubmenu: boolean +): ContextMenuSubmenuKeyboardAction | null { + if (hasSubmenu && (key === "Enter" || key === "ArrowRight")) { + return "open"; + } + if (insideSubmenu && (key === "ArrowLeft" || key === "Escape")) { + return "close"; + } + return null; +} + +function applyMenuItemSemantics(node: ReactNode): ReactNode { + return Children.map(node, (child) => { + if (!isValidElement(child)) { + return child; + } + const element = child as ReactElement<{ + children?: ReactNode; + disabled?: boolean; + role?: string; + tabIndex?: number; + }>; + if (typeof element.type === "string" && element.type === "button") { + return cloneElement(element, { role: "menuitem", tabIndex: -1 }); + } + if (element.props.children === undefined) { + return element; + } + return cloneElement(element, { children: applyMenuItemSemantics(element.props.children) }); + }); +} + +function getMenuItems(menu: HTMLElement | null): HTMLElement[] { + return Array.from(menu?.querySelectorAll("[role='menuitem']") ?? []).filter((item) => { + if (item.matches(":disabled") || item.getAttribute("aria-disabled") === "true") { + return false; + } + return item.getClientRects().length > 0; + }); +} + +function getTopLevelMenuItems(menu: HTMLElement | null): HTMLElement[] { + return getMenuItems(menu).filter((item) => !item.closest(".ctx-menu-sub-items")); +} + +function getSubmenuParts(item: HTMLElement | null): { + container: HTMLElement; + trigger: HTMLElement; + items: HTMLElement; +} | null { + const container = item?.closest(".ctx-menu-sub") ?? null; + if (!container) { + return null; + } + const trigger = Array.from(container.children).find((child) => child.matches("[role='menuitem']")); + const items = Array.from(container.children).find((child) => child.matches(".ctx-menu-sub-items")); + if (!(trigger instanceof HTMLElement) || !(items instanceof HTMLElement)) { + return null; + } + return { container, trigger, items }; +} + +function openSubmenu(parts: ReturnType): void { + if (!parts) { + return; + } + parts.container.classList.add("is-keyboard-open"); + parts.trigger.setAttribute("aria-expanded", "true"); + positionSubmenu(parts); + getMenuItems(parts.items)[0]?.focus(); +} + +function positionSubmenu(parts: NonNullable>): void { + const triggerRect = parts.trigger.getBoundingClientRect(); + const submenuRect = parts.items.getBoundingClientRect(); + const position = getContextSubmenuPosition( + triggerRect, + submenuRect, + { width: window.innerWidth, height: window.innerHeight } + ); parts.items.style.position = "fixed"; parts.items.style.left = `${position.x}px`; parts.items.style.top = `${position.y}px`; + parts.items.classList.add("is-positioned"); } - -function closeSubmenu(parts: ReturnType): void { - if (!parts) { - return; - } + +function closeSubmenu(parts: ReturnType): void { + if (!parts) { + return; + } parts.container.classList.remove("is-keyboard-open"); - parts.trigger.setAttribute("aria-expanded", "false"); - parts.trigger.focus(); -} - -export const ContextMenu = forwardRef(function ContextMenu({ - open, - x, - y, - onClose, - children, - ariaLabel = "Kontextmenü", - className = "", - ignoreOutsideRefs = [] -}, forwardedRef): ReactElement | null { - const menuRef = useRef(null); - const previousFocusRef = useRef(null); - const onCloseRef = useRef(onClose); - const ignoreOutsideRefsRef = useRef(ignoreOutsideRefs); - const [position, setPosition] = useState({ x, y }); - onCloseRef.current = onClose; - ignoreOutsideRefsRef.current = ignoreOutsideRefs; - useImperativeHandle(forwardedRef, () => menuRef.current as HTMLDivElement); - - useImmediateEffect(() => { - if (!open || !menuRef.current) { - return; - } - if (!previousFocusRef.current) { - previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; - } - const rect = menuRef.current.getBoundingClientRect(); - const next = clampContextMenuPosition(x, y, rect.width, rect.height, window.innerWidth, window.innerHeight); - setPosition((current) => current.x === next.x && current.y === next.y ? current : next); - getTopLevelMenuItems(menuRef.current)[0]?.focus(); - }, [open, x, y]); - - useEffect(() => { - if (!open) { - return; - } - const onOutside = (event: MouseEvent): void => { - const target = event.target; - if (!(target instanceof Node) || menuRef.current?.contains(target)) { - return; - } - if (ignoreOutsideRefsRef.current.some((ref) => ref.current?.contains(target))) { - return; - } - onCloseRef.current(); - }; - window.addEventListener("mousedown", onOutside); + parts.items.classList.remove("is-positioned"); + parts.trigger.setAttribute("aria-expanded", "false"); + parts.trigger.focus(); +} + +export const ContextMenu = forwardRef(function ContextMenu({ + open, + x, + y, + onClose, + children, + ariaLabel = "Kontextmenü", + className = "", + ignoreOutsideRefs = [] +}, forwardedRef): ReactElement | null { + const menuRef = useRef(null); + const previousFocusRef = useRef(null); + const onCloseRef = useRef(onClose); + const ignoreOutsideRefsRef = useRef(ignoreOutsideRefs); + const [position, setPosition] = useState({ x, y, sourceX: x, sourceY: y, ready: false }); + onCloseRef.current = onClose; + ignoreOutsideRefsRef.current = ignoreOutsideRefs; + useImperativeHandle(forwardedRef, () => menuRef.current as HTMLDivElement); + + useImmediateEffect(() => { + if (!open || !menuRef.current) { + return; + } + if (!previousFocusRef.current) { + previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null; + } + const rect = menuRef.current.getBoundingClientRect(); + const next = clampContextMenuPosition(x, y, rect.width, rect.height, window.innerWidth, window.innerHeight); + setPosition((current) => current.x === next.x && current.y === next.y && current.sourceX === x && current.sourceY === y && current.ready + ? current + : { ...next, sourceX: x, sourceY: y, ready: true }); + getTopLevelMenuItems(menuRef.current)[0]?.focus(); + }, [open, x, y]); + + useEffect(() => { + if (!open) { + return; + } + const onOutside = (event: MouseEvent): void => { + const target = event.target; + if (!(target instanceof Node) || menuRef.current?.contains(target)) { + return; + } + if (ignoreOutsideRefsRef.current.some((ref) => ref.current?.contains(target))) { + return; + } + onCloseRef.current(); + }; + window.addEventListener("pointerdown", onOutside, true); window.addEventListener("contextmenu", onOutside); return () => { - window.removeEventListener("mousedown", onOutside); - window.removeEventListener("contextmenu", onOutside); - const previousFocus = previousFocusRef.current; - previousFocusRef.current = null; - restoreFocus(previousFocus); - }; - }, [open]); - - if (!open) { - return null; - } - - const onKeyDown = (event: KeyboardEvent): void => { - const activeItem = document.activeElement instanceof HTMLElement ? document.activeElement : null; - const submenu = getSubmenuParts(activeItem); - const insideSubmenu = Boolean(activeItem?.closest(".ctx-menu-sub-items")); - const hasSubmenu = submenu?.trigger === activeItem; - const submenuAction = getContextMenuSubmenuKeyboardAction(event.key, hasSubmenu, insideSubmenu); - if (submenuAction) { - event.preventDefault(); - event.stopPropagation(); - if (submenuAction === "open") { - openSubmenu(submenu); - } else { - closeSubmenu(submenu); - } - return; - } - const submenuItems = insideSubmenu ? activeItem?.closest(".ctx-menu-sub-items") ?? null : null; - const items = submenuItems ? getMenuItems(submenuItems) : getTopLevelMenuItems(menuRef.current); - const currentIndex = items.findIndex((item) => item === document.activeElement); - const action = getContextMenuKeyboardAction(event.key, currentIndex, items.map(() => true)); - if (!action) { - return; - } - event.preventDefault(); - event.stopPropagation(); - if (action.type === "close") { - onClose(); - return; - } - if (action.type === "activate") { - items[action.index]?.click(); - return; - } - items[action.index]?.focus(); - }; - - return ( -
{ - event.stopPropagation(); - const item = event.target instanceof Element ? event.target.closest("[role='menuitem']") : null; - const submenu = getSubmenuParts(item); - if (submenu?.trigger === item) { - event.preventDefault(); - openSubmenu(submenu); - } - }} - onKeyDown={onKeyDown} - onMouseOver={(event) => { - const item = event.target instanceof Element ? event.target.closest("[role='menuitem']") : null; - const submenu = getSubmenuParts(item); - if (submenu?.trigger === item) { - positionSubmenu(submenu); - } - }} - ref={menuRef} - role="menu" - style={{ left: position.x, top: position.y }} - > - {applyMenuItemSemantics(children)} -
- ); -}); + window.removeEventListener("pointerdown", onOutside, true); + window.removeEventListener("contextmenu", onOutside); + const previousFocus = previousFocusRef.current; + previousFocusRef.current = null; + restoreFocus(previousFocus); + }; + }, [open]); + + if (!open) { + return null; + } + + const onKeyDown = (event: KeyboardEvent): void => { + const activeItem = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const submenu = getSubmenuParts(activeItem); + const insideSubmenu = Boolean(activeItem?.closest(".ctx-menu-sub-items")); + const hasSubmenu = submenu?.trigger === activeItem; + const submenuAction = getContextMenuSubmenuKeyboardAction(event.key, hasSubmenu, insideSubmenu); + if (submenuAction) { + event.preventDefault(); + event.stopPropagation(); + if (submenuAction === "open") { + openSubmenu(submenu); + } else { + closeSubmenu(submenu); + } + return; + } + const submenuItems = insideSubmenu ? activeItem?.closest(".ctx-menu-sub-items") ?? null : null; + const items = submenuItems ? getMenuItems(submenuItems) : getTopLevelMenuItems(menuRef.current); + const currentIndex = items.findIndex((item) => item === document.activeElement); + const action = getContextMenuKeyboardAction(event.key, currentIndex, items.map(() => true)); + if (!action) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (action.type === "close") { + onClose(); + return; + } + if (action.type === "activate") { + items[action.index]?.click(); + return; + } + items[action.index]?.focus(); + }; + + return ( +
{ + event.stopPropagation(); + const item = event.target instanceof Element ? event.target.closest("[role='menuitem']") : null; + const submenu = getSubmenuParts(item); + if (submenu?.trigger === item) { + event.preventDefault(); + openSubmenu(submenu); + } + }} + onKeyDown={onKeyDown} + onMouseOver={(event) => { + const item = event.target instanceof Element ? event.target.closest("[role='menuitem']") : null; + const submenu = getSubmenuParts(item); + if (submenu?.trigger === item) { + positionSubmenu(submenu); + } + }} + ref={menuRef} + role="menu" + style={{ left: position.x, top: position.y }} + > + {applyMenuItemSemantics(children)} +
+ ); +}); diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx index 4c35e84..1acc049 100644 --- a/src/renderer/views/downloads/DownloadsTable.tsx +++ b/src/renderer/views/downloads/DownloadsTable.tsx @@ -124,6 +124,12 @@ export function compactDownloadStatus(value: string): string { if (/Link wird umgewandelt/i.test(status)) return "Umwandeln"; if (/Download läuft\b/i.test(status)) return "Download läuft"; if (/Download running\b/i.test(status)) return "Download running"; + if (/^Passwort gefunden\b/i.test(status)) return "Passwort gefunden"; + if (/^Password found\b/i.test(status)) return "Password found"; + if (/^Entpack-Fehler\b/i.test(status)) return "Entpack-Fehler"; + if (/^Extraction error\b/i.test(status)) return "Extraction error"; + const extractionPending = status.match(/^(Entpacken|Extracting)\s*-\s*(Ausstehend|Pending|Warten auf Parts|Waiting for parts)/i); + if (extractionPending) return `${extractionPending[1]} - ${extractionPending[2]}`; const extracting = status.match(/Entpacken\s+(\d+)%/i); if (extracting) return `Entpacken - ${extracting[1]}%`; const extractingEnglish = status.match(/Extracting\s+(\d+)%/i); @@ -340,15 +346,15 @@ function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate, ); } -function packageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } { - let done = 0; +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 failed = 0; let cancelled = 0; - let extracted = 0; + let extracted = Math.max(0, Number(row.package.cleanedExtractedItemCount || 0)); let extracting = false; let activeProgress = 0; let extractingProgress = 0; - for (const item of row.items) { + for (const item of row.allItems) { if (item.status === "completed") done += 1; else if (item.status === "failed") failed += 1; else if (item.status === "cancelled") cancelled += 1; @@ -364,7 +370,7 @@ function packageProgress(row: DownloadPackageRow): { done: number; failed: numbe activeProgress += (item.progressPercent || 0) / 100; } } - const total = Math.max(1, row.items.length); + const total = Math.max(1, Math.max(0, Number(row.package.cleanedCompletedItemCount || 0)) + row.allItems.length); const allDownloaded = done + failed + cancelled >= total; const allExtracted = extracted >= total; const useExtractSplit = extracting || row.package.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0); @@ -374,9 +380,17 @@ function packageProgress(row: DownloadPackageRow): { done: number; failed: numbe return { done, failed, cancelled, total, value }; } +export function getPackageSizeProgress(row: DownloadPackageRow): { downloaded: number; total: number; value: number } { + const downloaded = Math.max(0, Number(row.package.cleanedDownloadedBytes || 0)) + + row.allItems.reduce((sum, item) => sum + item.downloadedBytes, 0); + const total = Math.max(0, Number(row.package.cleanedTotalBytes || 0)) + + row.allItems.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0); + return { downloaded, total, value: total > 0 ? progress((downloaded / total) * 100) : 0 }; +} + function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: number, editing: boolean, editingName: string, actions: DownloadsTableActions, finishRename: (value: string) => void): ReactElement | null { const entry = row.package; - const stats = packageProgress(row); + const stats = getPackageProgress(row); if (column === "name") { return ( @@ -397,9 +411,7 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n ); } if (column === "size") { - const total = row.items.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0); - const downloaded = row.items.reduce((sum, item) => sum + item.downloadedBytes, 0); - const value = total > 0 ? progress((downloaded / total) * 100) : 0; + const { downloaded, total, value } = getPackageSizeProgress(row); const text = `${humanSize(downloaded)} / ${humanSize(total)}`; return {total > 0 ? : null}; } @@ -415,18 +427,25 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n if (column === "prio") return {entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}; if (column === "status") { const audio = entry.audioStripSummary ? formatAudioStripSummary(entry.audioStripSummary) : null; - const details = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`; + const rawPostProcessLabel = entry.postProcessLabel?.trim() || ""; + const postProcessLabel = entry.status === "extracting" && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel) + ? "Entpacken - Ausstehend" + : compactDownloadStatus(rawPostProcessLabel); + const details = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`; const downloading = entry.status === "downloading" || entry.status === "validating" || row.items.some((item) => item.status === "downloading" || item.status === "validating"); - const status = entry.postProcessLabel && /Entpacken\s+\d+%/i.test(entry.postProcessLabel) - ? entry.postProcessLabel + const status = postProcessLabel && (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting") + ? postProcessLabel : downloading ? "Download läuft" : details; const title = audio?.tooltip ? `${details}\n${audio.tooltip}` : details; return ; } if (column === "speed") return {packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}; if (column === "availability") { - const availability = getAvailabilitySummary(row.items); - return ; + const availability = getAvailabilitySummary(row.allItems); + const text = availability.state === "checking" + ? row.allItems.some((item) => item.onlineStatus === "checking") ? "Prüfung" : "Ungeprüft" + : undefined; + return ; } if (column === "added") return {formatDateTime(entry.createdAt)}; return null; @@ -443,13 +462,9 @@ export interface PackageCardProps { columnOrder: readonly string[]; gridTemplate: string; actions: DownloadsTableActions; - draggable?: boolean; - onDragStart?: (packageId: string) => void; - onDrop?: (packageId: string) => void; - onDragEnd?: () => void; } -export function PackageCardContent({ row, selectedIds, editing, editingName, packageSpeedBps, sessionRunning = true, columnOrder, gridTemplate, actions, draggable = true, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement { +export function PackageCardContent({ row, selectedIds, editing, editingName, packageSpeedBps, sessionRunning = true, columnOrder, gridTemplate, actions }: PackageCardProps): ReactElement { const entry = row.package; let renameFinished = false; const finishRename = (value: string): void => { @@ -461,16 +476,12 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
{ event.preventDefault(); event.stopPropagation(); actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id); }} - onDragStart={(event) => { event.stopPropagation(); onDragStart?.(entry.id); }} - onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }} - onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop?.(entry.id); }} - onDragEnd={(event) => { event.stopPropagation(); onDragEnd?.(); }} + onDragStart={(event) => event.preventDefault()} >
void; onToggleAllPackages: () => void; onShowAllPackages: () => void; - onPackageDragStart: (packageId: string) => void; - onPackageDrop: (packageId: string) => void; - onPackageDragEnd: () => void; } const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [ @@ -152,10 +149,7 @@ function packageRows(model: DownloadsViewModel, actions: DownloadsViewActions): gridTemplate={model.gridTemplate} key={row.package.id} packageSpeedBps={model.packageSpeedBps[row.package.id] ?? 0} - onDragEnd={actions.onPackageDragEnd} - onDragStart={actions.onPackageDragStart} - onDrop={actions.onPackageDrop} - row={row} + row={row} selectedIds={model.selectedIds} selectedVersion={model.actionableSelectedIds.length} sessionRunning={model.running} diff --git a/src/renderer/views/downloads/downloads-model.ts b/src/renderer/views/downloads/downloads-model.ts index c028f45..210922a 100644 --- a/src/renderer/views/downloads/downloads-model.ts +++ b/src/renderer/views/downloads/downloads-model.ts @@ -27,11 +27,12 @@ export interface DownloadFilterCounts { failed: number; } -export interface DownloadPackageRow { - package: PackageEntry; - items: DownloadItem[]; - collapsed: boolean; -} +export interface DownloadPackageRow { + package: PackageEntry; + items: DownloadItem[]; + allItems: DownloadItem[]; + collapsed: boolean; +} export interface DownloadsViewModelCore { displayMode: DownloadDisplayMode; @@ -137,12 +138,13 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi const query = input.query.trim().toLocaleLowerCase("de-DE"); const collapsed = new Set(input.collapsedPackageIds); - const selectedIds = new Set(input.selectedIds); - let packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => { - const items = entry.itemIds - .map((id) => input.items[id]) - .filter((item): item is DownloadItem => Boolean(item)) - .filter((item) => !input.hideExtractedItems || !isExtracted(item)); + const selectedIds = new Set(input.selectedIds); + let packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => { + const allPackageItems = entry.itemIds + .map((id) => input.items[id]) + .filter((item): item is DownloadItem => Boolean(item)); + const items = allPackageItems + .filter((item) => !input.hideExtractedItems || !isExtracted(item)); const packageMatchesQuery = query === "" || matchesQuery(entry.name, query) || matchesQuery(entry.status, query); const matchingItems = items.filter((item) => { const itemMatchesQuery = query === "" @@ -158,8 +160,8 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi const visibleItems = packageMatchesQuery && query !== "" ? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter)) : matchingItems; - return [{ package: entry, items: visibleItems, collapsed: collapsed.has(entry.id) }]; - }); + return [{ package: entry, items: visibleItems, allItems: allPackageItems, collapsed: collapsed.has(entry.id) }]; + }); const totalPackageRows = packageRows.length; const allMatchingFileRows = packageRows.flatMap((row) => row.items); diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css index 15bf657..58c55f6 100644 --- a/src/renderer/views/downloads/downloads.css +++ b/src/renderer/views/downloads/downloads.css @@ -43,11 +43,13 @@ display: flex; min-height: 36px; align-items: center; + justify-content: center; padding: 0 10px; border: 1px solid var(--ui-border); border-radius: 6px; - color: var(--ui-text); - background: var(--ui-active); + color: #0a0f1a; + background: #90cdf4; + text-align: center; font-weight: 700; } @@ -397,6 +399,12 @@ text-overflow: ellipsis; } +.downloads-name-cell .downloads-rename-input { + flex: 1 1 auto; + width: 100%; + min-width: 0; +} + .downloads-selection-cell, .downloads-action-cell { display: flex; diff --git a/src/renderer/views/settings/AccountWorkspace.tsx b/src/renderer/views/settings/AccountWorkspace.tsx index b930065..6dd1358 100644 --- a/src/renderer/views/settings/AccountWorkspace.tsx +++ b/src/renderer/views/settings/AccountWorkspace.tsx @@ -220,9 +220,10 @@ function AccountRow({ {row.status.text} - {row.traffic} - {row.username} - {row.expires} + {row.traffic} + {row.username} + {row.email} + {row.expires} {row.credential} + ))} +
+ + {selectedOption ? ( + <> +
+ Zugangsdaten für {selectedOption.title} + {selectedOption.description} +
+ ) : null} {model.error ?

{model.error}

: null} diff --git a/src/renderer/views/settings/SettingsForm.tsx b/src/renderer/views/settings/SettingsForm.tsx index 031c6f8..a5f38ef 100644 --- a/src/renderer/views/settings/SettingsForm.tsx +++ b/src/renderer/views/settings/SettingsForm.tsx @@ -1,177 +1,269 @@ -import { cloneElement, type ChangeEvent, type ReactElement } from "react"; +import { cloneElement, useEffect, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, type ReactElement } from "react"; +import { getSettingsSelectNavigationIndex } from "./settings-model"; import type { SettingsFieldViewModel, SettingsFormViewModel, + SettingsSelectFieldViewModel, SettingsTextFieldViewModel } from "./settings-model"; - -export interface SettingsFormActions { - onChange: (fieldId: string, value: string | boolean) => void; - onAction: (fieldId: string) => void; - onCommit?: (fieldId: string, value: string) => void; -} - -export interface SettingsFormProps { - model: SettingsFormViewModel; - actions: SettingsFormActions; -} - -function FieldHelp({ field }: { field: SettingsFieldViewModel }): ReactElement | null { - return field.help ? {field.help} : null; -} - + +export interface SettingsFormActions { + onChange: (fieldId: string, value: string | boolean) => void; + onAction: (fieldId: string) => void; + onCommit?: (fieldId: string, value: string) => void; +} + +export interface SettingsFormProps { + model: SettingsFormViewModel; + actions: SettingsFormActions; +} + +function FieldHelp({ field }: { field: SettingsFieldViewModel }): ReactElement | null { + return field.help ? {field.help} : null; +} + function TextControl({ field, actions }: { field: SettingsTextFieldViewModel; actions: SettingsFormActions }): ReactElement { - const describedBy = field.help ? `${field.id}-help` : undefined; - const onChange = (event: ChangeEvent): void => { - actions.onChange(field.id, event.target.value); + const describedBy = field.help ? `${field.id}-help` : undefined; + const onChange = (event: ChangeEvent): void => { + actions.onChange(field.id, event.target.value); + }; + const control = field.kind === "textarea" ? ( +