From 7bd31185b0b261f024e9e3273c54cd13963feb41 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Mon, 24 Aug 2026 00:46:40 +0200 Subject: [PATCH] feat(downloads): restore sorting and stable availability Make Service sortable by visible provider labels, keep pointer sorting distinct from column dragging, preserve keyboard sorting, infer active and completed transfers as online, and retain definitive availability across item and package resets. Keep manual extraction and package-status changes out of this selective rebuild. --- src/main/download-manager.ts | 14 +- src/renderer/App.tsx | 35 ++++- .../views/downloads/DownloadsTable.tsx | 59 +++++++-- tests/download-manager.test.ts | 12 +- tests/download-sort.test.ts | 35 +++++ tests/downloads-view.test.tsx | 123 +++++++++++++++++- 6 files changed, 253 insertions(+), 25 deletions(-) create mode 100644 tests/download-sort.test.ts diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 21be0e9..d128cff 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -5708,10 +5708,9 @@ export class DownloadManager extends EventEmitter { item.lastError = ""; item.resumable = true; item.targetPath = ""; - item.provider = null; - item.fullStatus = "Wartet"; - item.onlineStatus = undefined; - item.updatedAt = nowMs(); + item.provider = null; + item.fullStatus = "Wartet"; + item.updatedAt = nowMs(); } const postProcessTasks = this.abortPackagePostProcessing(packageId, "reset"); @@ -5792,10 +5791,9 @@ export class DownloadManager extends EventEmitter { item.lastError = ""; item.resumable = true; item.targetPath = ""; - item.provider = null; - item.fullStatus = "Wartet"; - item.onlineStatus = undefined; - item.updatedAt = nowMs(); + item.provider = null; + item.fullStatus = "Wartet"; + item.updatedAt = nowMs(); if (this.session.running) { this.runItemIds.add(itemId); diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index af9fb6e..4a33741 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1404,7 +1404,7 @@ function sortPackageOrderByHoster(order: string[], packages: Record, items: Record, descending: boolean): string[] { +function sortPackageOrderByProgress(order: string[], packages: Record, items: Record, descending: boolean): string[] { const sorted = [...order]; sorted.sort((a, b) => { const progressA = computePackageProgress(packages[a], items); @@ -1412,8 +1412,27 @@ function sortPackageOrderByProgress(order: string[], packages: Record, + items: Record, + descending: boolean, + visibleItemsByPackage: Record = {} +): string[] { + const sorted = [...order]; + const itemsFor = (packageId: string): readonly DownloadItem[] => visibleItemsByPackage[packageId] + ?? (packages[packageId]?.itemIds ?? []).map((id) => items[id]).filter((item): item is DownloadItem => Boolean(item)); + sorted.sort((a, b) => { + const serviceA = [...new Set(itemsFor(a).map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean))].join(",").toLocaleLowerCase("de-DE"); + const serviceB = [...new Set(itemsFor(b).map((item) => item.providerLabel || (item.provider ? providerLabels[item.provider] : "")).filter(Boolean))].join(",").toLocaleLowerCase("de-DE"); + const comparison = serviceA.localeCompare(serviceB, "de"); + return descending ? -comparison : comparison; + }); + return sorted; +} function computePackageProgress(pkg: PackageEntry | undefined, items: Record): number { if (!pkg) return 0; @@ -4733,6 +4752,14 @@ export function App(): ReactElement { ? sortPackageOrderBySize(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending) : column === "hoster" ? sortPackageOrderByHoster(baseOrder, snapshot.session.packages, snapshot.session.items, nextDescending) + : column === "service" + ? sortPackageOrderByService( + baseOrder, + snapshot.session.packages, + snapshot.session.items, + nextDescending, + Object.fromEntries(downloadsViewCore.packageRows.map((row) => [row.package.id, row.items])) + ) : sortPackageOrderByName(baseOrder, snapshot.session.packages, nextDescending); pendingPackageOrderRef.current = [...sorted]; pendingPackageOrderAtRef.current = Date.now(); @@ -4745,7 +4772,7 @@ export function App(): ReactElement { setSnapshot((current) => ({ ...current, session: { ...current.session, packageOrder: serverPackageOrderRef.current } })); showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400); }); - }, [downloadsSortColumn, downloadsSortDescending, showToast, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]); + }, [downloadsSortColumn, downloadsSortDescending, downloadsViewCore.packageRows, showToast, snapshot.session.items, snapshot.session.packageOrder, snapshot.session.packages]); const clearDownloadQueue = useCallback((): void => { void performQuickAction(async () => { diff --git a/src/renderer/views/downloads/DownloadsTable.tsx b/src/renderer/views/downloads/DownloadsTable.tsx index eeb1c46..6939f94 100644 --- a/src/renderer/views/downloads/DownloadsTable.tsx +++ b/src/renderer/views/downloads/DownloadsTable.tsx @@ -14,12 +14,22 @@ import { } from "../../download-format"; import type { DownloadPackageRow } from "./downloads-model"; -export type DownloadSortColumn = "name" | "size" | "hoster" | "progress"; +export type DownloadSortColumn = "name" | "size" | "hoster" | "progress" | "service"; const DOWNLOAD_SELECTION_COLUMN_WIDTH = "36px"; const DOWNLOAD_ACTION_COLUMN_WIDTH = "60px"; +const DOWNLOAD_COLUMN_DRAG_THRESHOLD_PX = 5; const PACKAGE_ROW_DISCLOSURE_EXCLUSION_SELECTOR = "button, input, select, textarea, a, [contenteditable='true'], .downloads-copyable, .downloads-meter"; +interface DownloadColumnPointerGesture { + dragged: boolean; + pointerId: number; + sortColumn?: DownloadSortColumn; + startX: number; +} + +const downloadColumnPointerGestures = new WeakMap(); + type HosterLabel = ReturnType; function HosterLabelContent({ label }: { label: HosterLabel }): ReactElement { @@ -54,7 +64,7 @@ export const downloadColumnDefinitions: Record item.onlineStatus === "online").length; - const offline = items.filter((item) => item.onlineStatus === "offline").length; + const availability = items.map(effectiveItemOnlineStatus); + const online = availability.filter((status) => status === "online").length; + const offline = availability.filter((status) => status === "offline").length; if (total > 0 && online === total) return { online, total, state: "online" }; if (total > 0 && offline === total) return { online, total, state: "offline" }; if (total > 0 && online + offline === total) return { online, total, state: "partial" }; @@ -216,7 +232,7 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean): ? error && error !== displayStatus && !displayStatus.includes(error) ? `${displayStatus}${retrySuffix}\n${error}` : `${displayStatus}${retrySuffix}` : error; if (column === "name") { - return {item.fileName}; + return {item.fileName}; } if (column === "size") { const total = item.totalBytes || item.downloadedBytes || 0; @@ -241,7 +257,8 @@ function itemCell(item: DownloadItem, column: string, sessionRunning: boolean): if (column === "status") return ; if (column === "speed") return {item.speedBps > 0 ? formatSpeedMbps(item.speedBps) : ""}; if (column === "availability") { - const state = item.onlineStatus === "online" ? "online" : item.onlineStatus === "offline" ? "offline" : "checking"; + const effectiveStatus = effectiveItemOnlineStatus(item); + const state = effectiveStatus === "online" ? "online" : effectiveStatus === "offline" ? "offline" : "checking"; const text = state === "online" ? "Online" : state === "offline" ? "Offline" : item.onlineStatus === "checking" ? "Prüfung" : "Ungeprüft"; return ; } @@ -533,6 +550,11 @@ function moveColumnWithPointerActions(column: string, direction: -1 | 1, element actions.onColumnPointerUp(column, pointerEvent(clientX)); } +function isColumnSortPointerTarget(target: EventTarget | null): boolean { + const closest = (target as { closest?: (selector: string) => Element | null } | null)?.closest; + return typeof closest === "function" && closest.call(target, ".downloads-column-sort") !== null; +} + export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortColumn, sortDirection, selectedCount, visibleIds }: DownloadsTableHeaderProps): ReactElement { const allSelected = visibleIds.length > 0 && selectedCount === visibleIds.length; const mixedSelection = selectedCount > 0 && selectedCount < visibleIds.length; @@ -550,22 +572,41 @@ export function DownloadsTableHeader({ actions, columnOrder, gridTemplate, sortC data-download-column={column} key={column} onContextMenu={(event) => { event.preventDefault(); event.stopPropagation(); actions.onColumnContextMenu(column, event.clientX, event.clientY); }} - onPointerCancel={(event) => actions.onColumnPointerCancel(column, event)} + onPointerCancel={(event) => { + downloadColumnPointerGestures.delete(event.currentTarget); + actions.onColumnPointerCancel(column, event); + }} onPointerDown={(event) => { if (event.button !== 0 || !event.isPrimary) return; if (event.currentTarget.closest(".downloads-table")?.classList.contains("is-column-drag-settling")) return; + downloadColumnPointerGestures.set(event.currentTarget, { + dragged: false, + pointerId: event.pointerId, + sortColumn: definition.sortable && isColumnSortPointerTarget(event.target) ? definition.sortable : undefined, + startX: event.clientX + }); event.currentTarget.setPointerCapture(event.pointerId); actions.onColumnPointerDown(column, event); }} - onPointerMove={(event) => actions.onColumnPointerMove(column, event)} + onPointerMove={(event) => { + const gesture = downloadColumnPointerGestures.get(event.currentTarget); + if (gesture?.pointerId === event.pointerId && Math.abs(event.clientX - gesture.startX) >= DOWNLOAD_COLUMN_DRAG_THRESHOLD_PX) gesture.dragged = true; + actions.onColumnPointerMove(column, event); + }} onPointerUp={(event) => { + const gesture = downloadColumnPointerGestures.get(event.currentTarget); + if (gesture?.pointerId === event.pointerId) { + if (Math.abs(event.clientX - gesture.startX) >= DOWNLOAD_COLUMN_DRAG_THRESHOLD_PX) gesture.dragged = true; + downloadColumnPointerGestures.delete(event.currentTarget); + } if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); actions.onColumnPointerUp(column, event); + if (gesture?.pointerId === event.pointerId && gesture.sortColumn && !gesture.dragged) actions.onSortColumn(gesture.sortColumn); }} role="columnheader" > {definition.sortable - ? + ? : {definition.label}} event.stopPropagation()} role="group"> {index > 0 ? : null} diff --git a/tests/download-manager.test.ts b/tests/download-manager.test.ts index df60337..2ffbef5 100644 --- a/tests/download-manager.test.ts +++ b/tests/download-manager.test.ts @@ -8414,7 +8414,7 @@ describe("download manager", () => { expect(snap.settings.providerDailyUsageBytes || {}).toEqual({}); }); - it("resets extraction state atomically for selected package items", () => { + it("resets extraction state without discarding definitive link availability", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "rd-dm-")); tempDirs.push(root); const session = emptySession(); @@ -8470,7 +8470,7 @@ describe("download manager", () => { createStoragePaths(path.join(root, "state")) ); - manager.resetItems(itemIds); + await manager.resetItems(itemIds); const snapshot = manager.getSnapshot().session; expect(snapshot.packages[packageId]).toEqual(expect.objectContaining({ @@ -8486,9 +8486,15 @@ describe("download manager", () => { progressPercent: 0, lastError: "", fullStatus: "Wartet", - onlineStatus: undefined + onlineStatus: "online" })); } + + await manager.resetPackage(packageId); + const packageSnapshot = manager.getSnapshot().session; + for (const itemId of itemIds) { + expect(packageSnapshot.items[itemId].onlineStatus).toBe("online"); + } }); it("does not freeze the scheduler when a reset item's old task is parked in a non-abort-observing await", async () => { diff --git a/tests/download-sort.test.ts b/tests/download-sort.test.ts new file mode 100644 index 0000000..3fbf363 --- /dev/null +++ b/tests/download-sort.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { sortPackageOrderByService } from "../src/renderer/App"; + +describe("download package sorting", () => { + it("sorts the Service column by its visible provider labels", () => { + const packages = { + a: { id: "a", itemIds: ["item-a"] }, + b: { id: "b", itemIds: ["item-b"] } + } as any; + const items = { + "item-a": { provider: "realdebrid", providerLabel: "Real-Debrid" }, + "item-b": { provider: "debridlink", providerLabel: "Debrid-Link" } + } as any; + + expect(sortPackageOrderByService(["a", "b"], packages, items, false)).toEqual(["b", "a"]); + expect(sortPackageOrderByService(["a", "b"], packages, items, true)).toEqual(["a", "b"]); + }); + + it("uses filtered visible services instead of hidden package items", () => { + const packages = { + a: { id: "a", itemIds: ["a-hidden", "a-visible"] }, + b: { id: "b", itemIds: ["b-visible"] } + } as any; + const items = { + "a-visible": { provider: "debridlink", providerLabel: "ZZZ Visible" }, + "a-hidden": { provider: "realdebrid", providerLabel: "AAA Hidden" }, + "b-visible": { provider: "realdebrid", providerLabel: "Real-Debrid" } + } as any; + + expect(sortPackageOrderByService(["b", "a"], packages, items, false, { + a: [items["a-visible"]], + b: [items["b-visible"]] + })).toEqual(["b", "a"]); + }); +}); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index e525581..e586dd9 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -751,6 +751,35 @@ function findButton(node: ReactNode, label: string): ReactElement { return findElement(node, (element) => element.type === "button" && element.props.children === label); } +function dispatchColumnSortPointerGesture(header: ReactElement, label: string, clientXs: readonly number[], deliverPointerClick: boolean): void { + const startX = clientXs[0]; + const endX = clientXs[clientXs.length - 1]; + if (startX === undefined || endX === undefined) throw new Error("Pointer gesture requires coordinates"); + const columnHeader = findElement(header, (element) => element.props["data-download-column"] === "name"); + const sortButton = findElement(columnHeader, (element) => element.type === "button" && String(element.props.children).startsWith(label)); + const capturedPointers = new Set(); + const currentTarget = { + closest: () => null, + hasPointerCapture: (pointerId: number) => capturedPointers.has(pointerId), + releasePointerCapture: (pointerId: number) => capturedPointers.delete(pointerId), + setPointerCapture: (pointerId: number) => capturedPointers.add(pointerId) + }; + const target = { closest: (selector: string) => selector === ".downloads-column-sort" ? {} : null }; + const event = (clientX: number) => ({ + button: 0, + clientX, + currentTarget, + isPrimary: true, + pointerId: 7, + preventDefault: () => {}, + target + }); + columnHeader.props.onPointerDown(event(startX)); + clientXs.slice(1, -1).forEach((clientX) => columnHeader.props.onPointerMove(event(clientX))); + columnHeader.props.onPointerUp(event(endX)); + if (deliverPointerClick) sortButton.props.onClick({ detail: 1 }); +} + function withRuntime(input: DownloadsModelInput, overrides: Record = {}) { return { ...buildDownloadsViewModel(input), @@ -1326,6 +1355,23 @@ describe("download table row contracts", () => { expect(getAvailabilitySummary([ item("unknown-a", "package-a", "queued", { onlineStatus: undefined }) ])).toEqual({ online: 0, total: 1, state: "checking" }); + expect(getAvailabilitySummary([ + item("active-a", "package-a", "downloading", { onlineStatus: undefined }) + ])).toEqual({ online: 1, total: 1, state: "online" }); + }); + + it("shows an actively downloading item as online even without a stored availability result", () => { + const html = renderToStaticMarkup(ItemRowContent({ + actions: createActions(), + columnOrder: ["name", "availability"], + gridTemplate: "200px 150px", + item: item("active-availability", "package-a", "downloading", { onlineStatus: undefined }), + selected: false + })); + + expect(html).toContain(">Online"); + expect(html).not.toContain(">Ungeprüft"); + expect(html).toContain('class="downloads-link-state online"'); }); it("shows reset package availability as one compact unchecked label", () => { @@ -1582,7 +1628,7 @@ describe("download table row contracts", () => { expect(html).toMatch(/aria-sort="descending"[^>]*data-download-column="name"/); expect(html).toMatch(/aria-sort="none"[^>]*data-download-column="size"/); - expect(html).not.toMatch(/aria-sort="[^"]+"[^>]*data-download-column="account"/); + expect(html).toMatch(/aria-sort="none"[^>]*data-download-column="account"/); expect(moveLeft.props.type).toBe("button"); expect(calls).toEqual([ ["down", "size", 250], @@ -1591,6 +1637,81 @@ describe("download table row contracts", () => { ]); }); + it.each([ + { clientXs: [100, 100], deliverPointerClick: false }, + { clientXs: [100, 104, 104], deliverPointerClick: true } + ])("sorts exactly once when a captured pointer gesture stays below the drag threshold", ({ clientXs, deliverPointerClick }) => { + const sorted: string[] = []; + const header = DownloadsTableHeader({ + actions: createActions({ onSortColumn: (column) => sorted.push(column) }), + columnOrder: ["name", "size"], + gridTemplate: "200px 100px", + selectedCount: 0, + sortColumn: "name", + sortDirection: "asc", + visibleIds: [] + }); + + dispatchColumnSortPointerGesture(header, "Name", clientXs, deliverPointerClick); + + expect(sorted).toEqual(["name"]); + }); + + it("never sorts when a pointer gesture reaches the drag threshold", () => { + const sorted: string[] = []; + const header = DownloadsTableHeader({ + actions: createActions({ onSortColumn: (column) => sorted.push(column) }), + columnOrder: ["name", "size"], + gridTemplate: "200px 100px", + selectedCount: 0, + sortColumn: "name", + sortDirection: "asc", + visibleIds: [] + }); + + dispatchColumnSortPointerGesture(header, "Name", [100, 105, 101], true); + + expect(sorted).toEqual([]); + }); + + it("keeps sortable headers keyboard operable", () => { + const sorted: string[] = []; + const header = DownloadsTableHeader({ + actions: createActions({ onSortColumn: (column) => sorted.push(column) }), + columnOrder: ["name", "size"], + gridTemplate: "200px 100px", + selectedCount: 0, + sortColumn: "name", + sortDirection: "asc", + visibleIds: [] + }); + const sortButton = findElement(header, (element) => element.type === "button" && String(element.props.children).startsWith("Name")); + + sortButton.props.onClick({ detail: 0 }); + + expect(sorted).toEqual(["name"]); + }); + + it("exposes Service as a sortable column header", () => { + const sorted: string[] = []; + const header = DownloadsTableHeader({ + actions: createActions({ onSortColumn: (column) => sorted.push(column) }), + columnOrder: ["account"], + gridTemplate: "100px", + selectedCount: 0, + sortColumn: "service" as any, + sortDirection: "desc", + visibleIds: [] + }); + const serviceHeader = findElement(header, (element) => element.props["data-download-column"] === "account"); + const sortButton = findElement(serviceHeader, (element) => element.type === "button"); + + sortButton.props.onClick({ detail: 0 }); + + expect(serviceHeader.props["aria-sort"]).toBe("descending"); + expect(sorted).toEqual(["service"]); + }); + it("opens the column menu without letting the same context event close it again", () => { const calls: Array<[string, number, number]> = []; const header = DownloadsTableHeader({