diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 35bd6c3..969f412 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -75,6 +75,7 @@ import { CollectorInputDialog, CollectorSidebar, CollectorToolbar, + toggleAllCollectorPackageIds, type CollectorViewActions } from "./views/collector/CollectorView"; import { @@ -5113,6 +5114,10 @@ export function App(): ReactElement { else next.add(packageId); return next; }), + onToggleAllPackages: () => setCollapsedCollectorPackageIds((current) => toggleAllCollectorPackageIds( + collectorPackagesRef.current.map((pkg) => pkg.id), + current + )), onRemoveSelected: removeSelectedCollectorRows }; diff --git a/src/renderer/views/collector/CollectorView.tsx b/src/renderer/views/collector/CollectorView.tsx index 6c1f52f..013fae8 100644 --- a/src/renderer/views/collector/CollectorView.tsx +++ b/src/renderer/views/collector/CollectorView.tsx @@ -18,6 +18,7 @@ export interface CollectorViewActions { onLinkSelectionChange: (linkId: string, selected: boolean) => void; onPackageSelectionChange: (packageId: string, selected: boolean) => void; onPackageCollapseChange: (packageId: string) => void; + onToggleAllPackages: () => void; onRemoveSelected: () => void; } @@ -58,6 +59,15 @@ function packageSize(row: CollectorWorkspacePackageRow): string { return `${row.unknownSizeCount > 0 ? "≥ " : ""}${humanSize(row.totalBytes)}`; } +export function toggleAllCollectorPackageIds( + packageIds: readonly string[], + collapsedPackageIds: ReadonlySet +): Set { + return packageIds.some((packageId) => !collapsedPackageIds.has(packageId)) + ? new Set(packageIds) + : new Set(); +} + function CollectorHosterLabel({ hoster }: { hoster: ReturnType }): ReactElement { return ( @@ -94,7 +104,10 @@ export function CollectorToolbar({ model, actions }: CollectorViewProps): ReactE - actions.onQueryChange(event.target.value)} placeholder="Name, URL oder Hoster" value={model.query} /> + + actions.onQueryChange(event.target.value)} placeholder="Name, URL oder Hoster" value={model.query} /> + + ); } diff --git a/src/renderer/views/collector/collector.css b/src/renderer/views/collector/collector.css index 73b5d66..ec64f18 100644 --- a/src/renderer/views/collector/collector.css +++ b/src/renderer/views/collector/collector.css @@ -10,6 +10,8 @@ .collector-sidebar-filter span:last-child { color: var(--ui-text-muted); font-variant-numeric: tabular-nums; } .collector-toolbar { --collector-toolbar-action-gap: 4px; gap: var(--collector-toolbar-action-gap); min-width: 0; width: 100%; } .collector-toolbar .ui-toolbar-group { gap: var(--collector-toolbar-action-gap); } +.collector-toolbar-tail { flex: 1 1 auto; justify-content: flex-end; margin-left: auto; } +.collector-toolbar-tail .ui-toolbar-search { margin-left: 0; } .collector-action { background: var(--ui-input); border: 1px solid var(--ui-border); border-radius: 6px; color: var(--ui-text-secondary); font: inherit; height: 36px; padding: 0 12px; white-space: nowrap; } .collector-action:hover:not(:disabled) { background: var(--ui-hover); color: var(--ui-text); } .collector-action-primary { background: var(--ui-primary); border-color: var(--ui-primary); color: var(--ui-primary-text); } @@ -62,4 +64,4 @@ .collector-dialog-primary { background: var(--ui-primary); border-color: var(--ui-primary); color: var(--ui-primary-text); } .collector-dialog-secondary { background: var(--ui-modal-secondary); color: var(--ui-text-secondary); } @media (max-width: 1366px) { .collector-view { grid-template-columns: 56px minmax(0, 1fr); } .collector-sidebar { overflow: hidden; } .collector-sidebar-filter { padding: 0 7px; } .collector-sidebar-filter span:first-child { overflow: hidden; text-overflow: ellipsis; } .collector-action { padding: 0 9px; } .collector-table-header-row, .collector-package-row, .collector-file-row { grid-template-columns: 44px minmax(220px, 2.2fr) minmax(98px, .8fr) minmax(78px, .65fr) minmax(100px, .9fr) minmax(112px, 1fr) minmax(132px, 1fr); min-width: 820px; } } -@media (max-width: 1120px) { .collector-toolbar { flex-wrap: wrap; } .collector-toolbar .ui-toolbar-search { flex: 1 0 100%; width: 100%; } .collector-table-header-row, .collector-package-row, .collector-file-row { grid-template-columns: 42px minmax(200px, 2fr) minmax(92px, .8fr) minmax(72px, .65fr) minmax(94px, .9fr) minmax(106px, 1fr) minmax(124px, 1fr); min-width: 760px; } } +@media (max-width: 1120px) { .collector-toolbar { flex-wrap: wrap; } .collector-toolbar-tail { flex: 1 0 100%; } .collector-toolbar-tail .ui-toolbar-search { flex: 1 1 auto; width: auto; } .collector-table-header-row, .collector-package-row, .collector-file-row { grid-template-columns: 42px minmax(200px, 2fr) minmax(92px, .8fr) minmax(72px, .65fr) minmax(94px, .9fr) minmax(106px, 1fr) minmax(124px, 1fr); min-width: 760px; } } diff --git a/src/renderer/views/downloads/DownloadsView.tsx b/src/renderer/views/downloads/DownloadsView.tsx index 2a1e578..3536747 100644 --- a/src/renderer/views/downloads/DownloadsView.tsx +++ b/src/renderer/views/downloads/DownloadsView.tsx @@ -102,9 +102,8 @@ export function DownloadsSidebar({ actions, model }: { actions: DownloadsViewAct {filters.map((filter) => )} - +
-
@@ -151,11 +150,12 @@ export function DownloadsToolbar({ actions, model }: { actions: DownloadsViewAct - - - - ); -} + + + + + ); +} function tableState(model: DownloadsViewModel): ReactElement | null { if (model.empty) return
Noch keine DownloadsFüge Links hinzu, um den ersten Download zu starten.
; diff --git a/src/renderer/views/downloads/download-disclosure-transition.ts b/src/renderer/views/downloads/download-disclosure-transition.ts index 715e89f..d8fc373 100644 --- a/src/renderer/views/downloads/download-disclosure-transition.ts +++ b/src/renderer/views/downloads/download-disclosure-transition.ts @@ -62,7 +62,7 @@ function itemGroup( packageId, items: items.map((row) => ({ ...row, height: DOWNLOAD_FILE_ROW_HEIGHT })), height, - disclosureOpacity: 1, + disclosureOpacity: phase === "entering" ? 0 : 1, disclosurePhase: phase }; } @@ -197,8 +197,8 @@ export function activateDownloadDisclosureTransition(rows: readonly DownloadDisc return rows.map((row) => { if (row.type !== "item-group") return row; return row.disclosurePhase === "entering" - ? { ...row, height: row.items.length * DOWNLOAD_FILE_ROW_HEIGHT } - : { ...row, height: 0 }; + ? { ...row, height: row.items.length * DOWNLOAD_FILE_ROW_HEIGHT, disclosureOpacity: 1 } + : { ...row, height: 0, disclosureOpacity: 0 }; }); } diff --git a/src/renderer/views/downloads/downloads.css b/src/renderer/views/downloads/downloads.css index 5fbffbd..920a17a 100644 --- a/src/renderer/views/downloads/downloads.css +++ b/src/renderer/views/downloads/downloads.css @@ -237,6 +237,10 @@ background: var(--ui-border); } +.downloads-toolbar-toggle-all { + margin-left: auto; +} + .downloads-schedule-slot { display: grid; grid-template-columns: 0fr; diff --git a/tests/collector-view.test.tsx b/tests/collector-view.test.tsx index 97069f6..9b6a723 100644 --- a/tests/collector-view.test.tsx +++ b/tests/collector-view.test.tsx @@ -11,6 +11,7 @@ import { selectCollectorPackageLinks } from "../src/renderer/views/collector/collector-model"; import { CollectorContent, CollectorInputDialog, CollectorSidebar, CollectorToolbar, CollectorView, type CollectorViewActions } from "../src/renderer/views/collector/CollectorView"; +import * as collectorViewModule from "../src/renderer/views/collector/CollectorView"; function visitElements(node: ReactNode, visit: (element: ReactElement) => void): void { if (Array.isArray(node)) { @@ -48,6 +49,7 @@ function createActions(overrides: Partial = {}): Collector onLinkSelectionChange: () => {}, onPackageSelectionChange: () => {}, onPackageCollapseChange: () => {}, + onToggleAllPackages: () => {}, onRemoveSelected: () => {}, ...overrides }; @@ -111,6 +113,19 @@ describe("collector workspace model", () => { { id: "all", label: "Alle Links", count: 4 }, { id: "online", label: "Online", count: 2 }, { id: "unknown", label: "Ungeprüft", count: 1 }, { id: "offline", label: "Offline", count: 1 } ]); }); + + it("toggles every raw package even when the workspace shows only a filtered subset", () => { + const toggleAllCollectorPackageIds = (collectorViewModule as unknown as { + toggleAllCollectorPackageIds?: (packageIds: readonly string[], collapsedPackageIds: ReadonlySet) => Set; + }).toggleAllCollectorPackageIds; + expect(toggleAllCollectorPackageIds).toBeTypeOf("function"); + if (!toggleAllCollectorPackageIds) return; + + const rawPackageIds = packages.map((pkg) => pkg.id); + expect([...toggleAllCollectorPackageIds(rawPackageIds, new Set())].sort()).toEqual(["package-mixed", "package-sbs"]); + expect([...toggleAllCollectorPackageIds(rawPackageIds, new Set(["package-sbs"]))].sort()).toEqual(["package-mixed", "package-sbs"]); + expect([...toggleAllCollectorPackageIds(rawPackageIds, new Set(rawPackageIds))]).toEqual([]); + }); }); describe("CollectorView", () => { @@ -166,6 +181,24 @@ describe("CollectorView", () => { expect(all).toBe(1); }); + it("keeps the global package toggle enabled at the right toolbar edge despite active filters", () => { + let toggles = 0; + const model = buildCollectorWorkspaceViewModel(packages, "online", "no-visible-result", false, [], [], "", true); + const toolbar = CollectorToolbar({ actions: createActions({ onToggleAllPackages: () => { toggles += 1; } }), model }); + const toolbarButtons: ReactElement[] = []; + visitElements(toolbar, (element) => { + if (element.type === "button") toolbarButtons.push(element); + }); + const toggle = findButton(toolbar, "Alle ein-/ausklappen"); + + expect(model.empty).toBe(true); + expect(model.totalCount).toBe(4); + expect(toggle.props.disabled).toBe(false); + expect(toolbarButtons.at(-1)?.props.children).toBe("Alle ein-/ausklappen"); + toggle.props.onClick(); + expect(toggles).toBe(1); + }); + it("renders accessible mixed package selection", () => { const html = renderToStaticMarkup(); expect(html).toContain("aria-label=\"Paket SBS14HD auswählen\""); diff --git a/tests/downloads-view.test.tsx b/tests/downloads-view.test.tsx index 9db5939..3e203a6 100644 --- a/tests/downloads-view.test.tsx +++ b/tests/downloads-view.test.tsx @@ -371,7 +371,7 @@ describe("virtualisierte Paketanimation", () => { expect(prepared.animated).toBe(true); expect(prepared.rows.map((row) => [row.id, row.height, row.disclosurePhase, row.disclosureOpacity])).toEqual([ ["package-a", 40, "stable", 1], - ["package-a:items", 0, "entering", 1], + ["package-a:items", 0, "entering", 0], ["package-b", 40, "stable", 1], ["b-1", 38, "stable", 1] ]); @@ -411,7 +411,7 @@ describe("virtualisierte Paketanimation", () => { expect(preparedGroup?.type === "item-group" ? preparedGroup.items.map((row) => row.height) : []).toEqual([38, 38]); expect(activateDownloadDisclosureTransition(prepared.rows).map((row) => [row.id, row.height, row.disclosureOpacity])).toEqual([ ["package-a", 40, 1], - ["package-a:items", 0, 1], + ["package-a:items", 0, 0], ["package-b", 40, 1], ["b-1", 38, 1] ]); @@ -1098,6 +1098,23 @@ describe("downloads view", () => { expect(toolbar).not.toContain("downloads-search-input"); }); + it("places the global package disclosure action at the right toolbar edge", () => { + let toggles = 0; + const model = withRuntime(createInput({ filter: "paused" })); + const actions = createActions({ onToggleAllPackages: () => { toggles += 1; } }); + const sidebar = renderToStaticMarkup(); + const toolbar = DownloadsToolbar({ actions, model }); + const toolbarButtons: ReactElement[] = []; + visitElements(toolbar, (element) => { + if (element.type === "button") toolbarButtons.push(element); + }); + + expect(sidebar).not.toContain("Alle ein-/ausklappen"); + expect(toolbarButtons.at(-1)?.props.children).toBe("Alle ein-/ausklappen"); + findButton(toolbar, "Alle ein-/ausklappen").props.onClick(); + expect(toggles).toBe(1); + }); + it("blocks native package dragging while preserving explicit reorder actions", () => { const model = withRuntime(createInput()); const component = PackageCardContent({ diff --git a/tests/visual/download-disclosure-transition.test.tsx b/tests/visual/download-disclosure-transition.test.tsx new file mode 100644 index 0000000..eece668 --- /dev/null +++ b/tests/visual/download-disclosure-transition.test.tsx @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import type { DownloadLogicalRow } from "../../src/renderer/views/downloads/downloads-model"; +import { + activateDownloadDisclosureTransition, + prepareDownloadDisclosureTransition, + stableDownloadDisclosureRows, + type DownloadDisclosureRow +} from "../../src/renderer/views/downloads/download-disclosure-transition"; +import { + DOWNLOAD_FILE_ROW_HEIGHT, + DOWNLOAD_PACKAGE_ROW_HEIGHT +} from "../../src/renderer/views/downloads/download-virtualizer"; + +type DisclosureFrameRow = { + id: string; + top: number; + bottom: number; + opacity: number; +}; + +function packageRow(id: string, collapsed: boolean, itemIds: readonly string[]): DownloadLogicalRow { + return { + type: "package", + id, + packageId: id, + height: DOWNLOAD_PACKAGE_ROW_HEIGHT, + packageRow: { + id, + collapsed, + items: itemIds.map((itemId) => ({ id: itemId, packageId: id })) + } + } as unknown as DownloadLogicalRow; +} + +function itemRow(id: string, packageId: string): DownloadLogicalRow { + return { + type: "item", + id, + packageId, + height: DOWNLOAD_FILE_ROW_HEIGHT, + item: { id, packageId } + } as unknown as DownloadLogicalRow; +} + +function logicalRows(firstCollapsed: boolean): DownloadLogicalRow[] { + return [ + packageRow("package-a", firstCollapsed, ["a-1", "a-2"]), + ...(firstCollapsed ? [] : [itemRow("a-1", "package-a"), itemRow("a-2", "package-a")]), + packageRow("package-b", false, ["b-1"]), + itemRow("b-1", "package-b") + ]; +} + +function disclosureFrame( + startRows: readonly DownloadDisclosureRow[], + targetRows: readonly DownloadDisclosureRow[], + progress: number +): DisclosureFrameRow[] { + const targetById = new Map(targetRows.map((row) => [row.id, row])); + let top = 0; + return startRows.map((row) => { + const target = targetById.get(row.id) ?? row; + const height = row.height + (target.height - row.height) * progress; + const opacity = row.disclosureOpacity + (target.disclosureOpacity - row.disclosureOpacity) * progress; + const frameRow = { id: row.id, top, bottom: top + height, opacity }; + top += height; + return frameRow; + }); +} + +function prepareFrameRows(beforeCollapsed: boolean, afterCollapsed: boolean): { + start: DownloadDisclosureRow[]; + target: DownloadDisclosureRow[]; +} { + const prepared = prepareDownloadDisclosureTransition( + stableDownloadDisclosureRows(logicalRows(beforeCollapsed)), + logicalRows(afterCollapsed) + ); + expect(prepared.animated).toBe(true); + return { + start: prepared.rows, + target: activateDownloadDisclosureTransition(prepared.rows) + }; +} + +describe("download disclosure render contract", () => { + it.each([ + { beforeCollapsed: true, afterCollapsed: false, startOpacity: 0, targetOpacity: 1 }, + { beforeCollapsed: false, afterCollapsed: true, startOpacity: 1, targetOpacity: 0 } + ])("keeps the shared clipping group ahead of the following package in every measured frame", ({ beforeCollapsed, afterCollapsed, startOpacity, targetOpacity }) => { + const { start, target } = prepareFrameRows(beforeCollapsed, afterCollapsed); + const measuredFrames = [0, 0.2, 0.5, 0.8, 1].map((progress) => disclosureFrame(start, target, progress)); + + for (const frame of measuredFrames) { + const group = frame.find((row) => row.id === "package-a:items"); + const following = frame.find((row) => row.id === "package-b"); + expect(group).toBeDefined(); + expect(following).toBeDefined(); + expect(group!.bottom).toBeLessThanOrEqual(following!.top + 1); + } + + const groupOpacities = measuredFrames.map((frame) => frame.find((row) => row.id === "package-a:items")!.opacity); + expect(groupOpacities[0]).toBe(startOpacity); + expect(groupOpacities.at(-1)).toBe(targetOpacity); + expect(groupOpacities[2]).toBe((startOpacity + targetOpacity) / 2); + }); +});