Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Keyboard-accessible move-download-between-packages** (issue #134, PR #133 follow-up): the pointer-only HTML5 drag-and-drop introduced for moving downloads between packages now has a fully keyboard-driven and screen-reader-friendly alternative. Each `PackageDownloadRow` exposes a single toggle button (`Move…` ↔ `Cancel move`) that marks the download as pending-move; the button carries `aria-pressed` so screen readers announce the toggle state, replacing the deprecated `aria-grabbed` named in the original issue. Once a download is selected, every other `PackageRow` (the source package is intentionally excluded) surfaces a `Move selected here` action with an explicit `aria-label="Move selected download to {package}"`; the deprecated `aria-dropeffect` is intentionally not used because the descriptive button label plus the live region cover the same intent without the deprecated attribute. Activating the target reuses the same `package_remove_download` + `package_add_download` IPC pair as the drag-and-drop path (now factored into a shared `moveDownloadBetweenPackages` helper so the rollback logic stays in one place), surfaces start / success / cancel / error transitions through a new visually-hidden `role="status" aria-live="polite"` live region (`packages-move-live-region`), and matches the pointer path's visual feedback by raising `toast.success(packages.toast.moveDownloadSuccess)` on success and `toast.error(packages.toast.moveDownloadError)` in the catch (`toast.error(packages.toast.moveDownloadRollbackError)` still fires when both the move and the rollback fail). The `executeMove` finally block guards against an in-flight move clobbering a newer pending selection: `setPendingMove` is cleared only when the current state still references the move that just completed. The drag-and-drop path is unchanged. New i18n keys `packages.move.{start,startAriaLabel,cancel,cancelAriaLabel,target,targetAriaLabel,liveRegionLabel,announce.{selected,cancelled,started,success,error}}` ship in EN + FR. Six new Vitest tests cover the four issue acceptance criteria: keyboard initiation without a pointer (`Move` button → `aria-pressed` flips), live region announcements (selected / success / cancelled / error wording), unchanged pointer drag-and-drop (existing test still green), error-path coverage of the "target add fails, rollback succeeds" branch with explicit call-order assertions, and a race-condition guard that proves a stale `executeMove` does not erase a newer selection when its `package_remove_download` resolves late.

- **Auto-grouping playlist → package** (PRD §6.3, PRD-v2 §P1.11, task 30): when the Link Grabber resolves a YouTube or SoundCloud playlist URL, every selected item is now auto-attached to a single `Package` per playlist. New `PlaylistGrouper` application service (`src-tauri/src/application/services/playlist_grouper.rs`) finds-or-creates a `Package` keyed by the new `external_id` natural key (the playlist URL acts as the natural id), so re-resolving the same playlist reuses the existing package instead of creating a duplicate. New `GroupPlaylistsCommand` handler (`group_playlists.rs`) routes the request through the grouper; new `link_group_playlists` Tauri IPC command (`PlaylistGroupInputDto` → `Vec<PlaylistGroupResultDto>`) lets the frontend orchestrate the group-then-attach flow. Migration `m20260430_000008_add_package_external_id` adds `packages.external_id TEXT NULL` plus the `idx_packages_external_id` index; the column is intentionally nullable (manual packages keep `NULL`) and the application layer enforces uniqueness through `find_by_external_id` rather than a DB UNIQUE constraint that would forbid more than one manual row. Domain `Package` aggregate gains an `external_id: Option<String>` field with `set_external_id` and `external_id()` accessors; `PackageRepository::find_by_external_id` is added to the trait, returning the oldest row by `(created_at, id)` so repeated reuses pick the same package deterministically. The `MediaGrabberDialog` now shows a `PlaylistPackageBanner` ("Will create package: {name} with {N} items" / "Will reuse existing package …") above the playlist item list once `metadata.isPlaylist` is true, with EN + FR plural translations under `mediaGrabber.playlistBanner.*`. `LinkGrabberView.handleMediaGrabberConfirm` now calls `link_group_playlists` first when the user has selected playlist items, then `download_media_start`, then attaches every returned `downloadId` to the package via `package_add_download` (failures on attach are non-fatal — downloads still run, only the grouping is missed). Visibility of `application::commands::tests_support` is bumped to `pub(crate)` so the new `playlist_grouper` and `group_playlists` tests can reuse the existing `InMemoryPackageRepo` / `CapturingEventBus` mocks. 13 new tests cover the four acceptance criteria: 7 unit tests on `PlaylistGrouper` (create / reuse / blank id / fallback name / batch / partial failure / trim), 5 SQLite tests on `find_by_external_id` (round-trip / match / not-found / no NULL match / oldest-on-duplicates / clear-on-upsert), 3 handler tests, 4 frontend tests on `PlaylistPackageBanner`. cargo test 1327 passing, vitest 626 passing, clippy + fmt clean. Unblocks the Link Grabber's playlist UX.
- **Packages view** (PRD §6.3, PRD-v2 §P1.10, task 29): full Packages management UI replacing the previous `PlaceholderView`. New `src/views/PackagesView/` folder with `PackagesView` (root), `PackageToolbar` (filter chips `All / Container / Playlist / Manual / Split archive` + debounced search input + "New package" trigger), `PackageTree` (empty state + list of `PackageRow`), `PackageRow` (chevron toggle, inline rename trigger, source-type badge, file count via `_one`/`_other` plural keys, total bytes via `formatBytes`, aggregated `Progress` bar, folder browse button, `Key` password trigger, `Switch` for auto-extract, native `<select>` 1-10 priority, Pause-all / Start-all / Delete buttons), `PackageDownloadRow` (HTML5 native draggable child row with state badge + size + speed + ETA + per-row progress) and `PackageDialogs` (`AddPackageDialog`, `RenamePackageDialog`, `PasswordDialog` with `type="password"`, `FolderDialog` with `tauri-plugin-dialog` directory picker, `DeletePackageDialog` with optional "also delete child downloads" checkbox). New `src/types/package.ts` mirrors `PackageViewDto` (camelCase, no password field) plus `PackagePatch` / `PackageListFilter` / `CreatePackageInput` / `PackageMoveOutcome`. New `src/hooks/usePackagesQuery.ts` exposes `usePackagesQuery(filter?)` (TanStack Query, 30 s `staleTime`, forwards `sourceType` + `nameQ` to `package_list`) and `usePackageDownloadsQuery(packageId | null)` (lazy via `enabled`, 10 s `staleTime`, calls `package_list_downloads`). New `packageQueries` cache-key factory in `src/api/queries.ts` with `lists()` / `list(filter)` / `details()` / `detail(id)` / `downloads(id)` so mutations can target the right slice. The view wires every command from task 27: `package_create`, `package_update` (rename), `package_set_password` (keyring-only — UI never echoes the stored secret back), `package_set_priority`, `package_move_to_folder` (toast announces the count of moved children from `PackageMoveOutcome.moved.length`), `package_toggle_auto_extract`, `package_delete` (confirmation dialog with `deleteDownloads` boolean), `package_add_download` and `package_remove_download` (drag-and-drop pairing). Drag-and-drop uses native `dataTransfer` (no external lib): `PackageDownloadRow` sets `application/x-vortex-download` (id) + `application/x-vortex-source-package` (origin id) on `dragstart`; `PackageRow` registers itself as a drop zone via `data-testid="package-row-{id}-dropzone"` and the View's `dropDownload` handler short-circuits when `from === to`, parses the numeric id, calls `package_remove_download` then `package_add_download`, surfaces `moveDownloadSuccess` / `moveDownloadError` toasts, and invalidates the package cache. Bulk Pause-all / Start-all fans out the existing `download_pause` / `download_resume` IPC over `Promise.allSettled` for every member returned by `package_list_downloads`, then surfaces a single success toast or `bulkActionError` if any leg failed. Filter chips and the 300 ms debounced search (`useDebouncedValue`) re-key the `usePackagesQuery` so the round-trip happens server-side via `package_list { sourceType?, nameQ? }`; an empty filter object is collapsed to `undefined` so the SQL path takes the no-filter branch. Component boundary stays at 2 levels (View → Tree → Row) to honour the project's prop-drilling rule — dialogs are mounted at the View level and receive only the active target via state. New i18n namespace `packages.*` adds 60+ keys covering title / loading / empty / search placeholder / filter labels / row controls / dialog copy (Add / Rename / Password / Folder / Delete) / drag aria-labels / toast messages, with mirrored EN + FR translations and `_one` / `_other` plural variants for the file-count badge. `useTauriMutation`'s `invalidateKeys` array invalidates `packageQueries.all()` on every mutation; commands that touch downloads (`package_delete`, `package_move_to_folder`) additionally invalidate `downloadQueries.all()` so the main downloads list reflects the cascade. The legacy `src/views/PackagesView.tsx` placeholder file becomes a single-line re-export of the new folder, preserving every existing import path. The french translations test (`issue30-ui-fr.test.tsx`) is updated: `PackagesView` is no longer asserted as a placeholder; a new dedicated case asserts the FR header (`Paquets`) and search placeholder (`Rechercher des paquets`) render correctly with a real `QueryClientProvider`. 16 new Vitest tests cover the six acceptance criteria (tree expand/collapse, auto-extract toggle, masked password dialog, drag-and-drop FK update, ≥80 % coverage, ≤2-level prop drilling) plus filter chips, debounced search, dialog flows, fan-out bulk actions and the error state. Coverage on `src/views/PackagesView/`: 87.28 % statements / 90.07 % lines / 79.59 % functions — above the 80 % frontend threshold.
- **Packages queries** (PRD §6.3, PRD-v2 §P1.9, task 28): three CQRS query handlers (`list_packages`, `get_package`, `list_package_downloads`) wired through the `QueryBus` builder via a new `with_package_read_repo` setter. New driven port `PackageReadRepository` (`find_packages` / `find_package_by_id` / `find_package_downloads`) and `SqlitePackageReadRepo` adapter compute every package statistic (`downloads_count`, `total_bytes`, `downloaded_bytes`, `progress_percent`, `all_completed`) in a single `LEFT JOIN packages → downloads` with `GROUP BY p.id` so listing N packages costs one round-trip instead of `N+1`. `PackageFilter { source_type?, name_q? }` AND-combines filters: `source_type` is an exact match against the lowercase wire form (`container` / `playlist` / `manual` / `split_archive`) and is delegated to the SQL `WHERE` clause, while `name_q` is a case-insensitive substring (`LOWER(p.name) LIKE %?%`) so the UI can fuzzy-search package titles. Blank / whitespace-only `name_q` is treated as "no filter" so the UI can blindly forward an empty input. Aggregate progress mirrors the per-download formula (`Completed` always reports 100 even when `downloaded < total`, unknown total reports 0, otherwise `downloaded / total * 100` rounded to 1 dp); `all_completed` flips to `true` only when the package has at least one member and every member is in the `Completed` state. New read model `PackageViewDto` (`#[serde(rename_all = "camelCase")]`) re-exposes the aggregated `PackageView` to the frontend with no password / credential reference field, by construction. `list_package_downloads(id)` reuses the existing `DownloadView` so the React layer can render member rows with the same component as the main downloads list. Three Tauri IPC commands (`package_list`, `package_get`, `package_list_downloads`) registered in `invoke_handler!` and re-exported from `lib.rs`; `package_list` validates an unknown `source_type` argument up-front so callers see "invalid package source type" instead of an empty result. The runtime now wires `SqlitePackageReadRepo` to the `QueryBus` via `with_package_read_repo`. Twenty-three new unit + integration tests cover the three acceptance criteria (SQL-side stats with no N+1, fuzzy `name_q`, in-memory SQLite fixtures): aggregate vs empty package, mixed-state aggregation, all-completed flip, unknown total treated as zero, deterministic ordering by `(created_at, id)`, exact `source_type` filter, case-insensitive substring `name_q`, AND combination, blank `name_q` ignored, missing-id `None`, member ordering by `queue_position` then `id`, no leak across packages, validation errors when the read repo is missing, and DTO camelCase + no-password serialization assertions. Unblocks task 29 (Vue Packages React).
Expand Down
Loading
Loading