Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **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).
- **Packages commands** (PRD §6.3, PRD-v2 §P1.8, task 27): nine command handlers wired to the new `PackageRepository` and the existing `CredentialStore` via a `with_package_repo` builder on the `CommandBus`. `create_package(name, source_type, folder_path?)` generates a UUID v4 id, validates the trimmed name is non-empty, persists the aggregate and emits `DomainEvent::PackageCreated`. `update_package(id, PackagePatch)` applies a partial mutation (rename / folder / priority / auto_extract) — `folder_path` accepts `Some(Some(path))` to set, `Some(None)` to clear, `None` to leave untouched, so the frontend can distinguish "set to empty" from "unchanged". `delete_package(id, delete_downloads)` runs in two cascade modes: `false` (default) detaches every member via `PackageRepository::detach_download` so the downloads survive as standalone rows, `true` removes each member through the existing `RemoveDownloadCommand` (deletes engine state, files, and the SQLite row) before dropping the package row; the keyring entry under `vortex.package.<id>` is best-effort cleaned in both cases. `set_package_password(id, Option<String>)` stores the secret in the OS keyring via `CredentialStore::store("vortex.package.<id>", …)` and only writes the keyring service key (never the plaintext) onto the `packages.password` SQLite column as a marker; passing `None` clears both the keyring entry and the marker idempotently, and an explicit empty string is rejected as a validation error so callers cannot ambiguously "clear by emptying". `set_package_priority(id, priority)` validates the value through the domain `Priority` aggregate up-front (so a bad input never produces partial cascade state), persists the new value on the package row and then loops through every member returned by `list_downloads` to update each download's `priority` and emit a `DownloadPrioritySet` event per child — dangling FK members (download row missing) are skipped with a debug log instead of aborting the cascade. `move_package_to_folder(id, new_folder)` updates the package row's `folder_path` and re-uses task 13's `ChangeDirectoryCommand` for each member; per-child failures are collected into a `PackageMoveOutcome { moved, failed }` and surfaced to the frontend so partial failures don't roll back the package update. `toggle_package_auto_extract(id)` flips the flag and returns the new state. `add_download_to_package(package_id, download_id)` and `remove_download_from_package(package_id, download_id)` set / clear the FK on `downloads.package_id` via the new `attach_download` / `detach_download` trait methods; both validate the package exists first so the IPC layer surfaces a clean `NotFound` for stale callers, and `attach_download` also requires the download to exist (re-attaching is idempotent). The `PackageRepository` trait gains `attach_download(&PackageId, DownloadId) -> Result<(), DomainError>` (returns `NotFound` when the download row is missing) and `detach_download(DownloadId) -> Result<(), DomainError>` (idempotent, no-op on missing row); the `SqlitePackageRepo` adapter implements both via raw `UPDATE downloads SET package_id = ? WHERE id = ?` so the FK singleton semantics match the existing `ON DELETE SET NULL` migration. Two new `DomainEvent` variants — `PackageUpdated { id }` (rename / folder / priority / password / auto_extract / membership change) and `PackageDeleted { id, delete_downloads }` (the flag mirrors the command so subscribers distinguish "package detached, downloads kept" from "everything gone" without re-reading the repo) — are forwarded by the Tauri bridge as `package-updated` and `package-deleted` (camelCase `deleteDownloads`). Nine Tauri IPC commands (`package_create`, `package_update`, `package_delete`, `package_set_password`, `package_set_priority`, `package_move_to_folder`, `package_toggle_auto_extract`, `package_add_download`, `package_remove_download`) registered in `invoke_handler!` and re-exported from `lib.rs`, with a new `PackagePatchDto` deserialiser whose `folder_path: Option<Option<String>>` round-trips the three-state semantics from the frontend. The runtime now wires `SqlitePackageRepo` into the `CommandBus` via `with_package_repo`. Forty-three new unit tests against `InMemoryPackageRepo` / `InMemoryDownloadRepo` / `InMemoryCredentialStore` mocks cover every acceptance criterion: CRUD round-trip, cascade-delete vs detach, keyring-only password storage (the `packages.password` column never holds the plaintext), per-child `DownloadPrioritySet` cascade with an explicit count assertion, partial-failure outcome shape on bulk move, idempotent attach/detach, dangling-FK skip on the priority cascade, and validation paths for blank names, empty-string passwords, invalid priorities, missing repos, and unknown ids. Adapter coverage hovers at 95-99 % per file (well above the 85 % threshold). Five SQLite-level tests pin the new attach/detach semantics on a real in-memory DB. Unblocks task 29 (Vue Packages React).
- **Packages persistence** (PRD §6.3, PRD-v2 §P1.7, task 26): SQLite `packages` table (migration `m20260429_000007`) with the schema mandated by PRD-v2 §8 P1 — `id TEXT PRIMARY KEY`, `name`, `source_type` (`container` / `playlist` / `manual` / `split_archive`), nullable `folder_path`, nullable `password` (keyring ref), `auto_extract` (default `1`), `priority` (default `5`), `created_at`. The legacy stub `packages` table from migration 1 (BIGINT id, name only, never wired) is dropped and recreated. The migration also adds `downloads.package_id TEXT REFERENCES packages(id) ON DELETE SET NULL` plus the `idx_downloads_package` index, so deleting a package detaches its members without losing the rows. New `PackageRepository` driven port (`save` / `find_by_id` / `list` / `delete` / `list_downloads`) and `SqlitePackageRepo` adapter with sea-orm entity + `from_domain` / `into_domain` converters. Upserts preserve the original `created_at` so list ordering stays stable across re-saves; `list` orders by `(created_at asc, id asc)`; `list_downloads` orders by `queue_position asc, id asc` so the caller surfaces members in scheduling order. Domain `Package` aggregate gained the new persisted fields plus a `PackageId(String)` typed wrapper and a `PackageSourceType` enum (round-trips via `Display` / `FromStr`); `download_ids` stays in-memory (the FK on `downloads.package_id` is the source of truth on disk). `DomainEvent::PackageCreated.id` switches from `u64` to `PackageId` to match. Twenty-one new unit tests cover the four acceptance criteria (fresh + existing-DB migration, FK `ON DELETE SET NULL` semantics, full-field round-trip, ≥85 % adapter coverage), plus error paths (unknown `source_type`, priority overflow, `created_at` overflow), source-type round-trip per variant, optional fields persisting as `NULL`, `list_downloads` filtering and ordering, and the `InMemoryPackageRepository` mock used by future command / query handlers. Unblocks tasks 27 (Commands Packages), 28 (Queries Packages), 30 (auto-grouping playlist) and 31 (auto-grouping split archives).
Expand Down
11 changes: 11 additions & 0 deletions src/api/queries.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { AccountListFilter } from '@/types/account';
import type { DownloadFilter } from '@/types/download';
import type { PackageListFilter } from '@/types/package';

export const downloadQueries = {
all: () => ['downloads'] as const,
Expand Down Expand Up @@ -28,6 +29,16 @@ export const statsQueries = {
overview: () => [...statsQueries.all(), 'overview'] as const,
};

export const packageQueries = {
all: () => ['packages'] as const,
lists: () => [...packageQueries.all(), 'list'] as const,
list: (filter?: PackageListFilter) =>
filter ? ([...packageQueries.lists(), filter] as const) : (packageQueries.lists() as readonly unknown[]),
details: () => [...packageQueries.all(), 'detail'] as const,
detail: (id: string) => [...packageQueries.details(), id] as const,
downloads: (id: string) => [...packageQueries.all(), 'downloads', id] as const,
};

export const accountQueries = {
all: () => ['accounts'] as const,
lists: () => [...accountQueries.all(), 'list'] as const,
Expand Down
29 changes: 29 additions & 0 deletions src/hooks/usePackagesQuery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useQuery } from '@tanstack/react-query';
import { tauriInvoke } from '@/api/client';
import { packageQueries } from '@/api/queries';
import type { DownloadView } from '@/types/download';
import type { PackageListFilter, PackageView } from '@/types/package';

export function usePackagesQuery(filter?: PackageListFilter) {
return useQuery<PackageView[], Error>({
queryKey: filter ? packageQueries.list(filter) : packageQueries.lists(),
queryFn: () =>
tauriInvoke<PackageView[]>('package_list', {
sourceType: filter?.sourceType,
nameQ: filter?.nameQ,
}),
staleTime: 30_000,
});
}

export function usePackageDownloadsQuery(packageId: string | null) {
return useQuery<DownloadView[], Error>({
queryKey: packageId ? packageQueries.downloads(packageId) : ['packages', 'downloads', 'none'],
queryFn: () =>
tauriInvoke<DownloadView[]>('package_list_downloads', {
id: packageId,
}),
enabled: packageId !== null,
staleTime: 10_000,
});
}
11 changes: 10 additions & 1 deletion src/i18n/__tests__/issue30-ui-fr.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,6 @@ describe("issue #30 — French UI translations", () => {

it("renders placeholder views in French", () => {
const views = [
{ component: <PackagesView />, title: "Paquets" },
{ component: <CaptchaView />, title: "Captcha" },
{ component: <SchedulerView />, title: "Planificateur" },
];
Expand All @@ -141,6 +140,16 @@ describe("issue #30 — French UI translations", () => {
}
});

it("renders the Packages view header in French", async () => {
mockInvoke.mockImplementation(async (command: string) => {
if (command === "package_list") return [];
return undefined;
});
renderWithProviders(<PackagesView />);
expect(await screen.findByRole("heading", { name: "Paquets" })).toBeInTheDocument();
expect(screen.getByPlaceholderText("Rechercher des paquets")).toBeInTheDocument();
});

it("renders the Accounts view header in French", () => {
mockInvoke.mockResolvedValueOnce([]);
renderWithProviders(<AccountsView />);
Expand Down
Loading
Loading