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