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({