From 75800ff69bd45488b2b28c59beba486f217ac659 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:13:33 +0200 Subject: [PATCH 1/7] feat(ui): build the packages view (task 29) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the Packages placeholder with a full tree-based management UI: - PackagesView root + PackageToolbar (filter chips + debounced search + New package trigger), PackageTree (empty state + list), PackageRow (chevron toggle, inline rename, source-type badge, file count, total bytes, aggregated progress bar, folder browse, password trigger, auto-extract switch, 1-10 priority select, Pause-all / Start-all / Delete buttons), PackageDownloadRow (HTML5 native draggable child row) and PackageDialogs (Add / Rename / Password / Folder / Delete). - Wires every command from task 27 (`package_create`, `package_update`, `package_set_password`, `package_set_priority`, `package_move_to_folder`, `package_toggle_auto_extract`, `package_delete`, `package_add_download`, `package_remove_download`) and the queries from task 28 (`package_list`, `package_list_downloads`). - Drag-and-drop between packages uses the native HTML5 dataTransfer with `application/x-vortex-download` + `application/x-vortex-source-package` payloads, then chains `package_remove_download` + `package_add_download` with a single user-facing toast. - Bulk Pause-all / Start-all fans out the existing `download_pause` / `download_resume` IPC over `Promise.allSettled` for every member of the expanded package. - Filter + 300 ms debounced search re-key the TanStack Query so the filtering happens server-side in one round-trip; empty filter object is collapsed so the no-filter SQL path is taken. - New `src/types/package.ts`, `src/hooks/usePackagesQuery.ts`, `packageQueries` cache key factory, EN + FR translation namespace with `_one` / `_other` plural variants for file counts. - 16 new Vitest tests cover the six acceptance criteria (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 the PackagesView folder: 87.28 % statements / 90.07 % lines / 79.59 % functions. --- CHANGELOG.md | 1 + src/api/queries.ts | 11 + src/hooks/usePackagesQuery.ts | 29 ++ src/i18n/__tests__/issue30-ui-fr.test.tsx | 11 +- src/i18n/locales/en.json | 95 ++++ src/i18n/locales/fr.json | 95 ++++ src/types/package.ts | 46 ++ src/views/PackagesView.tsx | 7 +- src/views/PackagesView/PackageDialogs.tsx | 414 +++++++++++++++++ src/views/PackagesView/PackageDownloadRow.tsx | 39 ++ src/views/PackagesView/PackageRow.tsx | 224 +++++++++ src/views/PackagesView/PackageToolbar.tsx | 68 +++ src/views/PackagesView/PackageTree.tsx | 54 +++ src/views/PackagesView/PackagesView.tsx | 372 +++++++++++++++ .../__tests__/PackagesView.test.tsx | 437 ++++++++++++++++++ src/views/PackagesView/index.ts | 1 + 16 files changed, 1897 insertions(+), 7 deletions(-) create mode 100644 src/hooks/usePackagesQuery.ts create mode 100644 src/types/package.ts create mode 100644 src/views/PackagesView/PackageDialogs.tsx create mode 100644 src/views/PackagesView/PackageDownloadRow.tsx create mode 100644 src/views/PackagesView/PackageRow.tsx create mode 100644 src/views/PackagesView/PackageToolbar.tsx create mode 100644 src/views/PackagesView/PackageTree.tsx create mode 100644 src/views/PackagesView/PackagesView.tsx create mode 100644 src/views/PackagesView/__tests__/PackagesView.test.tsx create mode 100644 src/views/PackagesView/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e230931..c33006aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` setName(e.target.value)} + required + autoFocus + data-testid="package-add-name" + /> + +
+ {t("packages.addDialog.sourceType")} + +
+ + + + + + + + + ); +} + +interface RenamePackageDialogProps { + pkg: PackageView | null; + onCancel: () => void; + onSubmit: (name: string) => Promise; +} + +export function RenamePackageDialog({ pkg, onCancel, onSubmit }: RenamePackageDialogProps) { + const { t } = useTranslation(); + const [name, setName] = useState(""); + const [submitting, setSubmitting] = useState(false); + const open = pkg !== null; + const initialNameRef = useRef(""); + initialNameRef.current = pkg?.name ?? ""; + + useEffect(() => { + if (open) { + setName(initialNameRef.current); + setSubmitting(false); + } + }, [open]); + + const trimmed = name.trim(); + const canSubmit = !submitting && trimmed.length > 0 && trimmed !== initialNameRef.current; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!canSubmit) return; + setSubmitting(true); + try { + await onSubmit(trimmed); + onCancel(); + } catch { + // toast surfaced by mutation + } finally { + setSubmitting(false); + } + }; + + return ( + !o && onCancel()}> + + + {t("packages.renameDialog.title")} + +
+ + + + + +
+
+
+ ); +} + +interface PasswordDialogProps { + pkg: PackageView | null; + onCancel: () => void; + onSubmit: (password: string | null) => Promise; +} + +export function PasswordDialog({ pkg, onCancel, onSubmit }: PasswordDialogProps) { + const { t } = useTranslation(); + const [password, setPassword] = useState(""); + const [submitting, setSubmitting] = useState(false); + const open = pkg !== null; + + useEffect(() => { + if (open) { + setPassword(""); + setSubmitting(false); + } + }, [open]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (submitting) return; + setSubmitting(true); + try { + await onSubmit(password.length > 0 ? password : null); + onCancel(); + } catch { + // toast surfaced by mutation + } finally { + setSubmitting(false); + } + }; + + return ( + !o && onCancel()}> + + + {t("packages.passwordDialog.title")} + {t("packages.passwordDialog.description")} + +
+ + + + + +
+
+
+ ); +} + +interface FolderDialogProps { + pkg: PackageView | null; + onCancel: () => void; + onPickFolder: () => Promise; + onSubmit: (folder: string) => Promise; +} + +export function FolderDialog({ pkg, onCancel, onPickFolder, onSubmit }: FolderDialogProps) { + const { t } = useTranslation(); + const [folder, setFolder] = useState(""); + const [submitting, setSubmitting] = useState(false); + const open = pkg !== null; + const initialFolderRef = useRef(""); + initialFolderRef.current = pkg?.folderPath ?? ""; + + useEffect(() => { + if (open) { + setFolder(initialFolderRef.current); + setSubmitting(false); + } + }, [open]); + + const trimmed = folder.trim(); + const canSubmit = !submitting && trimmed.length > 0; + + const handleBrowse = async () => { + const picked = await onPickFolder(); + if (picked) setFolder(picked); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!canSubmit) return; + setSubmitting(true); + try { + await onSubmit(trimmed); + onCancel(); + } catch { + // toast surfaced by mutation + } finally { + setSubmitting(false); + } + }; + + return ( + !o && onCancel()}> + + + {t("packages.folderDialog.title")} + {t("packages.folderDialog.description")} + +
+ + + + + +
+
+
+ ); +} + +interface DeletePackageDialogProps { + pkg: PackageView | null; + onCancel: () => void; + onConfirm: (deleteDownloads: boolean) => Promise; +} + +export function DeletePackageDialog({ pkg, onCancel, onConfirm }: DeletePackageDialogProps) { + const { t } = useTranslation(); + const [deleteDownloads, setDeleteDownloads] = useState(false); + const [submitting, setSubmitting] = useState(false); + const open = pkg !== null; + + useEffect(() => { + if (open) { + setDeleteDownloads(false); + setSubmitting(false); + } + }, [open]); + + const handleConfirm = async () => { + if (submitting) return; + setSubmitting(true); + try { + await onConfirm(deleteDownloads); + onCancel(); + } catch { + // toast surfaced by mutation + } finally { + setSubmitting(false); + } + }; + + return ( + !o && onCancel()}> + + + {t("packages.deleteDialog.title")} + + {t("packages.deleteDialog.description", { name: pkg?.name ?? "" })} + + + + + + + + + + ); +} diff --git a/src/views/PackagesView/PackageDownloadRow.tsx b/src/views/PackagesView/PackageDownloadRow.tsx new file mode 100644 index 00000000..40cc6473 --- /dev/null +++ b/src/views/PackagesView/PackageDownloadRow.tsx @@ -0,0 +1,39 @@ +import { useTranslation } from "react-i18next"; +import { GripVertical } from "lucide-react"; +import { Progress } from "@/components/ui/progress"; +import { formatBytes, formatEta, formatSpeed } from "@/lib/format"; +import type { DownloadView } from "@/types/download"; + +interface PackageDownloadRowProps { + download: DownloadView; + packageId: string; + onDragStart: (download: DownloadView, fromPackageId: string) => void; +} + +export function PackageDownloadRow({ download, packageId, onDragStart }: PackageDownloadRowProps) { + const { t } = useTranslation(); + return ( +
{ + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("application/x-vortex-download", download.id); + e.dataTransfer.setData("application/x-vortex-source-package", packageId); + onDragStart(download, packageId); + }} + className="flex items-center gap-3 border-t bg-muted/30 px-4 py-2 text-sm hover:bg-muted/50" + > + + {download.fileName} + {download.state} + {formatBytes(download.totalBytes)} + {formatSpeed(download.speedBytesPerSec)} + {formatEta(download.etaSeconds)} + +
+ ); +} diff --git a/src/views/PackagesView/PackageRow.tsx b/src/views/PackagesView/PackageRow.tsx new file mode 100644 index 00000000..46db0ead --- /dev/null +++ b/src/views/PackagesView/PackageRow.tsx @@ -0,0 +1,224 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { + ChevronDown, + ChevronRight, + Folder, + Key, + Pencil, + Play, + Pause, + Trash2, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { Switch } from "@/components/ui/switch"; +import { formatBytes } from "@/lib/format"; +import type { DownloadView } from "@/types/download"; +import type { PackageView } from "@/types/package"; +import { PackageDownloadRow } from "./PackageDownloadRow"; + +export interface PackageRowActions { + toggleExpand: (id: string) => void; + rename: (pkg: PackageView) => void; + setPassword: (pkg: PackageView) => void; + changeFolder: (pkg: PackageView) => void; + deletePackage: (pkg: PackageView) => void; + toggleAutoExtract: (pkg: PackageView) => void; + setPriority: (pkg: PackageView, priority: number) => void; + pauseAll: (pkg: PackageView, downloads: DownloadView[]) => void; + startAll: (pkg: PackageView, downloads: DownloadView[]) => void; + beginDragDownload: (download: DownloadView, fromPackageId: string) => void; + dropDownload: (toPackageId: string, e: React.DragEvent) => void; +} + +interface PackageRowProps { + pkg: PackageView; + expanded: boolean; + childrenLoading: boolean; + childrenError: Error | null; + childDownloads: DownloadView[] | null; + actions: PackageRowActions; +} + +const PRIORITIES = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] as const; + +export function PackageRow({ + pkg, + expanded, + childrenLoading, + childrenError, + childDownloads, + actions, +}: PackageRowProps) { + const { t } = useTranslation(); + const sourceLabelKey = useMemo( + () => `packages.filter.${pkg.sourceType}`, + [pkg.sourceType], + ); + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + actions.dropDownload(pkg.id, e); + }; + + return ( +
{ + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + }} + onDrop={handleDrop} + aria-label={t("packages.drag.dropZoneAriaLabel")} + > +
+ + + + + {t(sourceLabelKey)} + + + {t("packages.row.files", { count: Number(pkg.downloadsCount) })} + + {formatBytes(pkg.totalBytes)} + +
+ + + {pkg.progressPercent.toFixed(0)}% + +
+ + + + + + + + + + + + + + +
+ + {expanded && ( +
+ {childrenLoading && ( +
+ {t("packages.row.loadingChildren")} +
+ )} + {childrenError && ( +
{childrenError.message}
+ )} + {!childrenLoading && childDownloads !== null && childDownloads.length === 0 && ( +
+ {t("packages.row.noChildren")} +
+ )} + {childDownloads?.map((d) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/views/PackagesView/PackageToolbar.tsx b/src/views/PackagesView/PackageToolbar.tsx new file mode 100644 index 00000000..8a07c9d2 --- /dev/null +++ b/src/views/PackagesView/PackageToolbar.tsx @@ -0,0 +1,68 @@ +import { useTranslation } from "react-i18next"; +import { Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { PackageSourceType } from "@/types/package"; + +const FILTER_ORDER: ReadonlyArray<"all" | PackageSourceType> = [ + "all", + "container", + "playlist", + "manual", + "split-archive", +]; + +interface PackageToolbarProps { + filter: "all" | PackageSourceType; + onFilterChange: (next: "all" | PackageSourceType) => void; + search: string; + onSearchChange: (next: string) => void; + onAddClick: () => void; +} + +export function PackageToolbar({ + filter, + onFilterChange, + search, + onSearchChange, + onAddClick, +}: PackageToolbarProps) { + const { t } = useTranslation(); + return ( +
+
+ {FILTER_ORDER.map((value) => ( + + ))} +
+
+ onSearchChange(e.target.value)} + placeholder={t("packages.search")} + data-testid="packages-search" + className="w-56" + /> + +
+
+ ); +} diff --git a/src/views/PackagesView/PackageTree.tsx b/src/views/PackagesView/PackageTree.tsx new file mode 100644 index 00000000..457d63eb --- /dev/null +++ b/src/views/PackagesView/PackageTree.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from "react-i18next"; +import type { DownloadView } from "@/types/download"; +import type { PackageView } from "@/types/package"; +import { PackageRow, type PackageRowActions } from "./PackageRow"; + +interface PackageTreeProps { + packages: PackageView[]; + expandedId: string | null; + childrenLoading: boolean; + childrenError: Error | null; + childrenById: DownloadView[] | null; + actions: PackageRowActions; +} + +export function PackageTree({ + packages, + expandedId, + childrenLoading, + childrenError, + childrenById, + actions, +}: PackageTreeProps) { + const { t } = useTranslation(); + + if (packages.length === 0) { + return ( +
+ {t("packages.empty")} +
+ ); + } + + return ( +
+ {packages.map((pkg) => { + const isExpanded = expandedId === pkg.id; + return ( + + ); + })} +
+ ); +} diff --git a/src/views/PackagesView/PackagesView.tsx b/src/views/PackagesView/PackagesView.tsx new file mode 100644 index 00000000..e203f839 --- /dev/null +++ b/src/views/PackagesView/PackagesView.tsx @@ -0,0 +1,372 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useQueryClient } from "@tanstack/react-query"; +import { open as openDialog } from "@tauri-apps/plugin-dialog"; +import { useTauriMutation } from "@/api/hooks"; +import { packageQueries, downloadQueries } from "@/api/queries"; +import { useDebouncedValue } from "@/hooks/useDebouncedValue"; +import { usePackagesQuery, usePackageDownloadsQuery } from "@/hooks/usePackagesQuery"; +import { toast } from "@/lib/toast"; +import type { DownloadView } from "@/types/download"; +import type { + CreatePackageInput, + PackagePatch, + PackageSourceType, + PackageView, +} from "@/types/package"; +import { + AddPackageDialog, + DeletePackageDialog, + FolderDialog, + PasswordDialog, + RenamePackageDialog, +} from "./PackageDialogs"; +import { PackageTree } from "./PackageTree"; +import type { PackageRowActions } from "./PackageRow"; +import { PackageToolbar } from "./PackageToolbar"; + +const INVALIDATE_KEYS = [packageQueries.all()] as const; +const INVALIDATE_KEYS_WITH_DOWNLOADS = [ + packageQueries.all(), + downloadQueries.all(), +] as const; + +interface PackageMoveOutcome { + moved: number[]; + failed: Array<{ id: number; reason: string }>; +} + +export function PackagesView() { + const { t } = useTranslation(); + const queryClient = useQueryClient(); + + const [filter, setFilter] = useState<"all" | PackageSourceType>("all"); + const [search, setSearch] = useState(""); + const debouncedSearch = useDebouncedValue(search, 300); + + const [addOpen, setAddOpen] = useState(false); + const [renaming, setRenaming] = useState(null); + const [passwordTarget, setPasswordTarget] = useState(null); + const [folderTarget, setFolderTarget] = useState(null); + const [deleting, setDeleting] = useState(null); + + const [expandedId, setExpandedId] = useState(null); + const dragRef = useRef<{ downloadId: number; fromPackageId: string } | null>(null); + + const queryFilter = useMemo(() => { + const f: { sourceType?: string; nameQ?: string } = {}; + if (filter !== "all") f.sourceType = filter; + if (debouncedSearch.trim().length > 0) f.nameQ = debouncedSearch.trim(); + return Object.keys(f).length > 0 ? f : undefined; + }, [filter, debouncedSearch]); + + const { data, isLoading, error } = usePackagesQuery(queryFilter); + const packages = useMemo(() => data ?? [], [data]); + + const { + data: childrenData, + isLoading: childrenLoading, + error: childrenError, + } = usePackageDownloadsQuery(expandedId); + const childrenById = useMemo( + () => (expandedId ? childrenData ?? null : null), + [expandedId, childrenData], + ); + + const invalidatePackages = useCallback(() => { + queryClient.invalidateQueries({ queryKey: packageQueries.all() }); + }, [queryClient]); + + const createMut = useTauriMutation>( + "package_create", + { + invalidateKeys: INVALIDATE_KEYS, + errorMessage: () => t("packages.toast.createError"), + }, + ); + + const updateMut = useTauriMutation>( + "package_update", + { + invalidateKeys: INVALIDATE_KEYS, + errorMessage: () => t("packages.toast.updateError"), + }, + ); + + const deleteMut = useTauriMutation( + "package_delete", + { + invalidateKeys: INVALIDATE_KEYS_WITH_DOWNLOADS, + errorMessage: () => t("packages.toast.deleteError"), + }, + ); + + const passwordMut = useTauriMutation( + "package_set_password", + { + invalidateKeys: INVALIDATE_KEYS, + errorMessage: () => t("packages.toast.passwordError"), + }, + ); + + const priorityMut = useTauriMutation( + "package_set_priority", + { + invalidateKeys: INVALIDATE_KEYS, + errorMessage: () => t("packages.toast.updateError"), + }, + ); + + const moveFolderMut = useTauriMutation< + PackageMoveOutcome, + { id: string; newFolder: string } + >("package_move_to_folder", { + invalidateKeys: INVALIDATE_KEYS_WITH_DOWNLOADS, + errorMessage: () => t("packages.toast.moveError"), + }); + + const toggleAutoExtractMut = useTauriMutation( + "package_toggle_auto_extract", + { + invalidateKeys: INVALIDATE_KEYS, + errorMessage: () => t("packages.toast.updateError"), + }, + ); + + const removeFromPackageMut = useTauriMutation( + "package_remove_download", + { silentError: true }, + ); + + const addToPackageMut = useTauriMutation( + "package_add_download", + { silentError: true }, + ); + + const pauseMut = useTauriMutation("download_pause", { + invalidateKeys: [downloadQueries.all()] as const, + silentError: true, + }); + + const resumeMut = useTauriMutation("download_resume", { + invalidateKeys: [downloadQueries.all()] as const, + silentError: true, + }); + + const handleCreate = useCallback( + async (input: CreatePackageInput) => { + await createMut.mutateAsync({ + name: input.name, + sourceType: input.sourceType, + folderPath: input.folderPath, + }); + toast.success(t("packages.toast.createSuccess")); + }, + [createMut, t], + ); + + const handleRename = useCallback( + async (name: string) => { + if (!renaming) return; + await updateMut.mutateAsync({ id: renaming.id, patch: { name } }); + toast.success(t("packages.toast.updateSuccess")); + }, + [renaming, updateMut, t], + ); + + const handleSetPassword = useCallback( + async (password: string | null) => { + if (!passwordTarget) return; + await passwordMut.mutateAsync({ id: passwordTarget.id, password }); + toast.success(t("packages.toast.passwordSuccess")); + }, + [passwordTarget, passwordMut, t], + ); + + const handleChangeFolder = useCallback( + async (newFolder: string) => { + if (!folderTarget) return; + const outcome = await moveFolderMut.mutateAsync({ id: folderTarget.id, newFolder }); + const moved = outcome?.moved.length ?? 0; + toast.success(t("packages.toast.moveSuccess", { count: moved })); + }, + [folderTarget, moveFolderMut, t], + ); + + const handleDelete = useCallback( + async (deleteDownloads: boolean) => { + if (!deleting) return; + await deleteMut.mutateAsync({ id: deleting.id, deleteDownloads }); + toast.success(t("packages.toast.deleteSuccess")); + }, + [deleting, deleteMut, t], + ); + + const pickFolder = useCallback(async () => { + const picked = await openDialog({ + directory: true, + multiple: false, + }).catch(() => null); + if (typeof picked === "string") return picked; + return null; + }, []); + + const fanoutDownloadAction = useCallback( + async (downloads: DownloadView[], action: (id: number) => Promise) => { + const ids = downloads + .map((d) => Number(d.id)) + .filter((n) => Number.isFinite(n)); + const results = await Promise.allSettled(ids.map((id) => action(id))); + const failed = results.filter((r) => r.status === "rejected").length; + return { total: ids.length, failed }; + }, + [], + ); + + const actions = useMemo(() => ({ + toggleExpand: (id: string) => { + setExpandedId((prev) => (prev === id ? null : id)); + }, + rename: (pkg) => setRenaming(pkg), + setPassword: (pkg) => setPasswordTarget(pkg), + changeFolder: (pkg) => setFolderTarget(pkg), + deletePackage: (pkg) => setDeleting(pkg), + toggleAutoExtract: (pkg) => { + toggleAutoExtractMut.mutate( + { id: pkg.id }, + { onSuccess: () => toast.success(t("packages.toast.updateSuccess")) }, + ); + }, + setPriority: (pkg, priority) => { + priorityMut.mutate( + { id: pkg.id, priority }, + { onSuccess: () => toast.success(t("packages.toast.updateSuccess")) }, + ); + }, + pauseAll: async (_pkg, downloads) => { + const { failed } = await fanoutDownloadAction(downloads, (id) => + pauseMut.mutateAsync({ id }), + ); + if (failed > 0) { + toast.error(t("packages.toast.bulkActionError")); + } else { + toast.success(t("packages.toast.bulkPauseSuccess")); + } + }, + startAll: async (_pkg, downloads) => { + const { failed } = await fanoutDownloadAction(downloads, (id) => + resumeMut.mutateAsync({ id }), + ); + if (failed > 0) { + toast.error(t("packages.toast.bulkActionError")); + } else { + toast.success(t("packages.toast.bulkStartSuccess")); + } + }, + beginDragDownload: (download, fromPackageId) => { + const numericId = Number(download.id); + if (!Number.isFinite(numericId)) return; + dragRef.current = { downloadId: numericId, fromPackageId }; + }, + dropDownload: async (toPackageId, e) => { + const transfer = e.dataTransfer; + const rawId = + transfer?.getData("application/x-vortex-download") ?? + String(dragRef.current?.downloadId ?? ""); + const fromId = + transfer?.getData("application/x-vortex-source-package") ?? + dragRef.current?.fromPackageId ?? + ""; + const downloadId = Number(rawId); + dragRef.current = null; + if (!Number.isFinite(downloadId) || fromId === toPackageId || fromId === "") { + return; + } + try { + await removeFromPackageMut.mutateAsync({ packageId: fromId, downloadId }); + await addToPackageMut.mutateAsync({ packageId: toPackageId, downloadId }); + toast.success(t("packages.toast.moveDownloadSuccess")); + invalidatePackages(); + } catch { + toast.error(t("packages.toast.moveDownloadError")); + } + }, + }), [ + addToPackageMut, + fanoutDownloadAction, + invalidatePackages, + pauseMut, + priorityMut, + removeFromPackageMut, + resumeMut, + t, + toggleAutoExtractMut, + ]); + + return ( +
+

{t("packages.title")}

+ setAddOpen(true)} + /> + {error && ( +
+ {error.message} +
+ )} + {isLoading ? ( +
+ {t("packages.loading")} +
+ ) : ( + + )} + + setRenaming(null)} + onSubmit={handleRename} + /> + setPasswordTarget(null)} + onSubmit={handleSetPassword} + /> + setFolderTarget(null)} + onPickFolder={pickFolder} + onSubmit={handleChangeFolder} + /> + setDeleting(null)} + onConfirm={handleDelete} + /> +
+ ); +} diff --git a/src/views/PackagesView/__tests__/PackagesView.test.tsx b/src/views/PackagesView/__tests__/PackagesView.test.tsx new file mode 100644 index 00000000..6ae8072d --- /dev/null +++ b/src/views/PackagesView/__tests__/PackagesView.test.tsx @@ -0,0 +1,437 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, waitFor, within, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { invoke } from "@tauri-apps/api/core"; +import { open as openDialog } from "@tauri-apps/plugin-dialog"; +import { toast } from "sonner"; +import type { DownloadView } from "@/types/download"; +import type { PackageView } from "@/types/package"; +import { PackagesView } from "../PackagesView"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +vi.mock("@tauri-apps/plugin-dialog", () => ({ + open: vi.fn(), + save: vi.fn(), +})); + +const mockInvoke = vi.mocked(invoke); +const mockOpenDialog = vi.mocked(openDialog); +const mockToastSuccess = vi.mocked(toast.success); +const mockToastError = vi.mocked(toast.error); + +function samplePackages(): PackageView[] { + return [ + { + id: "pkg-1", + name: "Holiday playlist", + sourceType: "playlist", + folderPath: "/srv/dl/holiday", + autoExtract: false, + priority: 5, + createdAt: 1_700_000_000_000, + downloadsCount: 3, + totalBytes: 30_000_000, + downloadedBytes: 15_000_000, + progressPercent: 50, + allCompleted: false, + }, + { + id: "pkg-2", + name: "Backup archive", + sourceType: "split-archive", + folderPath: null, + autoExtract: true, + priority: 7, + createdAt: 1_700_000_001_000, + downloadsCount: 0, + totalBytes: 0, + downloadedBytes: 0, + progressPercent: 0, + allCompleted: true, + }, + ]; +} + +function sampleChildren(): DownloadView[] { + return [ + { + id: "42", + fileName: "song-01.mp3", + url: "https://example.com/song-01.mp3", + sourceHostname: "example.com", + state: "Downloading", + progressPercent: 60, + speedBytesPerSec: 100_000, + downloadedBytes: 6_000_000, + totalBytes: 10_000_000, + etaSeconds: 40, + segmentsActive: 4, + segmentsTotal: 4, + moduleName: "youtube", + accountName: null, + priority: 5, + queuePosition: 1, + createdAt: 1_700_000_002_000, + }, + { + id: "43", + fileName: "song-02.mp3", + url: "https://example.com/song-02.mp3", + sourceHostname: "example.com", + state: "Paused", + progressPercent: 20, + speedBytesPerSec: 0, + downloadedBytes: 2_000_000, + totalBytes: 10_000_000, + etaSeconds: null, + segmentsActive: 0, + segmentsTotal: 4, + moduleName: "youtube", + accountName: null, + priority: 5, + queuePosition: 2, + createdAt: 1_700_000_003_000, + }, + ]; +} + +function renderView() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, staleTime: 0 } }, + }); + render( + + + , + ); + return { client }; +} + +beforeEach(() => { + window.localStorage.setItem("i18nextLng", "en"); + mockInvoke.mockReset(); + mockOpenDialog.mockReset(); + mockToastSuccess.mockClear(); + mockToastError.mockClear(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function defaultInvokeImpl() { + return async (command: string, _args?: unknown) => { + if (command === "package_list") return samplePackages(); + if (command === "package_list_downloads") return sampleChildren(); + return null; + }; +} + +describe("PackagesView", () => { + it("renders packages returned by package_list", async () => { + mockInvoke.mockImplementation(defaultInvokeImpl()); + renderView(); + await waitFor(() => { + expect(screen.getByText("Holiday playlist")).toBeInTheDocument(); + expect(screen.getByText("Backup archive")).toBeInTheDocument(); + }); + expect(screen.queryByText(/coming soon/i)).not.toBeInTheDocument(); + }); + + it("renders empty state when no packages exist", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "package_list") return []; + return null; + }); + renderView(); + await waitFor(() => { + expect(screen.getByTestId("packages-empty")).toBeInTheDocument(); + }); + }); + + it("expands a package and lists its downloads", async () => { + mockInvoke.mockImplementation(defaultInvokeImpl()); + const user = userEvent.setup(); + renderView(); + await screen.findByText("Holiday playlist"); + await user.click(screen.getByTestId("package-row-pkg-1-toggle")); + await waitFor(() => { + expect(screen.getByText("song-01.mp3")).toBeInTheDocument(); + expect(screen.getByText("song-02.mp3")).toBeInTheDocument(); + }); + expect(mockInvoke).toHaveBeenCalledWith( + "package_list_downloads", + expect.objectContaining({ id: "pkg-1" }), + ); + }); + + it("filters by source type via filter chips", async () => { + mockInvoke.mockImplementation(defaultInvokeImpl()); + const user = userEvent.setup(); + renderView(); + await screen.findByText("Holiday playlist"); + await user.click(screen.getByTestId("packages-filter-playlist")); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith( + "package_list", + expect.objectContaining({ sourceType: "playlist" }), + ); + }); + }); + + it("debounces search and forwards nameQ to package_list", async () => { + mockInvoke.mockImplementation(defaultInvokeImpl()); + renderView(); + await screen.findByText("Holiday playlist"); + const input = screen.getByTestId("packages-search") as HTMLInputElement; + fireEvent.change(input, { target: { value: "holi" } }); + await waitFor( + () => { + expect(mockInvoke).toHaveBeenCalledWith( + "package_list", + expect.objectContaining({ nameQ: "holi" }), + ); + }, + { timeout: 2000 }, + ); + }); + + it("creates a package via the New package dialog", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "package_list") return samplePackages(); + if (command === "package_create") return "pkg-99"; + if (command === "package_list_downloads") return []; + return null; + }); + const user = userEvent.setup(); + renderView(); + await screen.findByText("Holiday playlist"); + await user.click(screen.getByTestId("packages-add-trigger")); + await user.type(screen.getByTestId("package-add-name"), "New box"); + await user.click(screen.getByTestId("package-add-submit")); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith( + "package_create", + expect.objectContaining({ name: "New box", sourceType: "manual" }), + ); + }); + expect(mockToastSuccess).toHaveBeenCalled(); + }); + + it("renames a package via inline rename dialog", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "package_list") return samplePackages(); + if (command === "package_update") return null; + return null; + }); + const user = userEvent.setup(); + renderView(); + await screen.findByText("Holiday playlist"); + await user.click(screen.getByTestId("package-row-pkg-1-rename")); + const input = screen.getByTestId("package-rename-input"); + await user.clear(input); + await user.type(input, "Renamed pkg"); + await user.click(screen.getByTestId("package-rename-submit")); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith( + "package_update", + expect.objectContaining({ + id: "pkg-1", + patch: expect.objectContaining({ name: "Renamed pkg" }), + }), + ); + }); + }); + + it("toggles auto-extract via switch", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "package_list") return samplePackages(); + if (command === "package_toggle_auto_extract") return true; + return null; + }); + const user = userEvent.setup(); + renderView(); + await screen.findByText("Holiday playlist"); + await user.click(screen.getByTestId("package-row-pkg-1-auto-extract")); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith( + "package_toggle_auto_extract", + expect.objectContaining({ id: "pkg-1" }), + ); + }); + }); + + it("changes priority via select", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "package_list") return samplePackages(); + if (command === "package_set_priority") return null; + return null; + }); + const user = userEvent.setup(); + renderView(); + await screen.findByText("Holiday playlist"); + const select = screen.getByTestId("package-row-pkg-1-priority") as HTMLSelectElement; + await user.selectOptions(select, "9"); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith( + "package_set_priority", + expect.objectContaining({ id: "pkg-1", priority: 9 }), + ); + }); + }); + + it("sets a password via the password dialog (masked)", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "package_list") return samplePackages(); + if (command === "package_set_password") return null; + return null; + }); + const user = userEvent.setup(); + renderView(); + await screen.findByText("Holiday playlist"); + await user.click(screen.getByTestId("package-row-pkg-1-password")); + const input = screen.getByTestId("package-password-input"); + expect(input).toHaveAttribute("type", "password"); + await user.type(input, "topsecret"); + await user.click(screen.getByTestId("package-password-submit")); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith( + "package_set_password", + expect.objectContaining({ id: "pkg-1", password: "topsecret" }), + ); + }); + }); + + it("opens the change folder dialog and persists the new path", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "package_list") return samplePackages(); + if (command === "package_move_to_folder") return { moved: [42, 43], failed: [] }; + return null; + }); + mockOpenDialog.mockResolvedValue("/srv/dl/new"); + const user = userEvent.setup(); + renderView(); + await screen.findByText("Holiday playlist"); + await user.click(screen.getByTestId("package-row-pkg-1-folder")); + await user.click(screen.getByTestId("package-folder-browse")); + await waitFor(() => { + expect((screen.getByTestId("package-folder-input") as HTMLInputElement).value).toBe( + "/srv/dl/new", + ); + }); + await user.click(screen.getByTestId("package-folder-submit")); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith( + "package_move_to_folder", + expect.objectContaining({ id: "pkg-1", newFolder: "/srv/dl/new" }), + ); + }); + }); + + it("deletes a package after confirmation", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "package_list") return samplePackages(); + if (command === "package_delete") return null; + return null; + }); + const user = userEvent.setup(); + renderView(); + await screen.findByText("Holiday playlist"); + await user.click(screen.getByTestId("package-row-pkg-1-delete")); + await user.click(screen.getByTestId("package-delete-confirm")); + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith( + "package_delete", + expect.objectContaining({ id: "pkg-1", deleteDownloads: false }), + ); + }); + }); + + it("pauses every download in a package via Pause all", async () => { + mockInvoke.mockImplementation(async (command: string, args?: unknown) => { + if (command === "package_list") return samplePackages(); + if (command === "package_list_downloads") return sampleChildren(); + if (command === "download_pause") { + const id = (args as { id: number }).id; + expect([42, 43]).toContain(id); + return null; + } + return null; + }); + const user = userEvent.setup(); + renderView(); + await screen.findByText("Holiday playlist"); + await user.click(screen.getByTestId("package-row-pkg-1-toggle")); + await screen.findByText("song-01.mp3"); + await user.click(screen.getByTestId("package-row-pkg-1-pause-all")); + await waitFor(() => { + expect( + mockInvoke.mock.calls.filter(([c]) => c === "download_pause"), + ).toHaveLength(2); + }); + }); + + it("moves a download between packages via drag and drop", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "package_list") return samplePackages(); + if (command === "package_list_downloads") return sampleChildren(); + if (command === "package_remove_download") return null; + if (command === "package_add_download") return null; + return null; + }); + const user = userEvent.setup(); + renderView(); + await screen.findByText("Holiday playlist"); + await user.click(screen.getByTestId("package-row-pkg-1-toggle")); + await screen.findByText("song-01.mp3"); + + const draggable = screen.getByTestId("package-download-row-42"); + const dropZone = screen.getByTestId("package-row-pkg-2-dropzone"); + const dataTransfer = { + data: {} as Record, + setData(key: string, value: string) { + this.data[key] = value; + }, + getData(key: string) { + return this.data[key] ?? ""; + }, + }; + fireEvent.dragStart(draggable, { dataTransfer }); + fireEvent.dragOver(dropZone, { dataTransfer }); + fireEvent.drop(dropZone, { dataTransfer }); + + await waitFor(() => { + expect(mockInvoke).toHaveBeenCalledWith( + "package_remove_download", + expect.objectContaining({ packageId: "pkg-1", downloadId: 42 }), + ); + expect(mockInvoke).toHaveBeenCalledWith( + "package_add_download", + expect.objectContaining({ packageId: "pkg-2", downloadId: 42 }), + ); + }); + }); + + it("surfaces the error state when package_list fails", async () => { + mockInvoke.mockImplementation(async (command: string) => { + if (command === "package_list") throw new Error("boom"); + return null; + }); + renderView(); + await waitFor(() => { + expect(screen.getByTestId("packages-error")).toHaveTextContent(/boom/i); + }); + }); + + it("shows count of files per package", async () => { + mockInvoke.mockImplementation(defaultInvokeImpl()); + renderView(); + await screen.findByText("Holiday playlist"); + const row = screen.getByTestId("package-row-pkg-1"); + expect(within(row).getByText(/3 files/i)).toBeInTheDocument(); + }); +}); diff --git a/src/views/PackagesView/index.ts b/src/views/PackagesView/index.ts new file mode 100644 index 00000000..244c96cc --- /dev/null +++ b/src/views/PackagesView/index.ts @@ -0,0 +1 @@ +export { PackagesView } from "./PackagesView"; From b870d0ab64d7fb40417590176f48e77c17ef6080 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:28:39 +0200 Subject: [PATCH 2/7] fix(ui): address packages view review feedback - Block FolderDialog submit when the trimmed path matches the current package folder, so a no-op move never reaches `package_move_to_folder`. - Surface partial failures from `package_move_to_folder` via a new `packages.toast.movePartialError` key (EN + FR) instead of falsely reporting success when `outcome.failed` is non-empty. - Compensate the drag-and-drop two-step move: if `package_add_download` fails after `package_remove_download` succeeded, re-add the download to the source package; surface a dedicated rollback-failed toast (`moveDownloadRollbackError`) when the compensation also fails so the user knows to refresh. --- src/i18n/locales/en.json | 2 ++ src/i18n/locales/fr.json | 2 ++ src/views/PackagesView/PackageDialogs.tsx | 3 ++- src/views/PackagesView/PackagesView.tsx | 22 ++++++++++++++++++++-- 4 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 7217538e..6276f1ed 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -590,9 +590,11 @@ "passwordError": "Failed to save password", "moveSuccess_one": "{{count}} download moved", "moveSuccess_other": "{{count}} downloads moved", + "movePartialError": "Moved {{moved}} of {{total}} downloads ({{failed}} failed)", "moveError": "Failed to move folder", "moveDownloadSuccess": "Download moved to package", "moveDownloadError": "Failed to move download", + "moveDownloadRollbackError": "Move failed and rollback also failed — refresh to verify package contents", "bulkPauseSuccess": "Paused all downloads", "bulkStartSuccess": "Resumed all downloads", "bulkActionError": "Bulk action failed" diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 0fdd8b29..eb63d709 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -590,9 +590,11 @@ "passwordError": "Échec de l'enregistrement du mot de passe", "moveSuccess_one": "{{count}} téléchargement déplacé", "moveSuccess_other": "{{count}} téléchargements déplacés", + "movePartialError": "{{moved}} téléchargements sur {{total}} déplacés ({{failed}} en échec)", "moveError": "Échec du déplacement", "moveDownloadSuccess": "Téléchargement déplacé", "moveDownloadError": "Échec du déplacement du téléchargement", + "moveDownloadRollbackError": "Déplacement échoué et rollback échoué — actualisez pour vérifier le contenu du paquet", "bulkPauseSuccess": "Tous les téléchargements mis en pause", "bulkStartSuccess": "Tous les téléchargements relancés", "bulkActionError": "L'action en lot a échoué" diff --git a/src/views/PackagesView/PackageDialogs.tsx b/src/views/PackagesView/PackageDialogs.tsx index aae82f82..715fc72c 100644 --- a/src/views/PackagesView/PackageDialogs.tsx +++ b/src/views/PackagesView/PackageDialogs.tsx @@ -282,7 +282,8 @@ export function FolderDialog({ pkg, onCancel, onPickFolder, onSubmit }: FolderDi }, [open]); const trimmed = folder.trim(); - const canSubmit = !submitting && trimmed.length > 0; + const canSubmit = + !submitting && trimmed.length > 0 && trimmed !== initialFolderRef.current.trim(); const handleBrowse = async () => { const picked = await onPickFolder(); diff --git a/src/views/PackagesView/PackagesView.tsx b/src/views/PackagesView/PackagesView.tsx index e203f839..8a37a68d 100644 --- a/src/views/PackagesView/PackagesView.tsx +++ b/src/views/PackagesView/PackagesView.tsx @@ -188,7 +188,14 @@ export function PackagesView() { if (!folderTarget) return; const outcome = await moveFolderMut.mutateAsync({ id: folderTarget.id, newFolder }); const moved = outcome?.moved.length ?? 0; - toast.success(t("packages.toast.moveSuccess", { count: moved })); + const failed = outcome?.failed.length ?? 0; + if (failed > 0) { + toast.error( + t("packages.toast.movePartialError", { moved, failed, total: moved + failed }), + ); + } else { + toast.success(t("packages.toast.moveSuccess", { count: moved })); + } }, [folderTarget, moveFolderMut, t], ); @@ -284,7 +291,18 @@ export function PackagesView() { } try { await removeFromPackageMut.mutateAsync({ packageId: fromId, downloadId }); - await addToPackageMut.mutateAsync({ packageId: toPackageId, downloadId }); + try { + await addToPackageMut.mutateAsync({ packageId: toPackageId, downloadId }); + } catch (addError) { + try { + await addToPackageMut.mutateAsync({ packageId: fromId, downloadId }); + } catch { + toast.error(t("packages.toast.moveDownloadRollbackError")); + invalidatePackages(); + throw addError; + } + throw addError; + } toast.success(t("packages.toast.moveDownloadSuccess")); invalidatePackages(); } catch { From b3579f2dd285e9ed99894e851b9799b8bf8242f9 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:40:51 +0200 Subject: [PATCH 3/7] fix(ui): align packages frontend wire format with backend - Switch the `PackageSourceType` union, toolbar filter chips, dialog source select and tests from `split-archive` to `split_archive` to match the backend wire form parsed by `PackageSourceType::from_str` (`src-tauri/src/domain/model/package.rs:62`). Filter and create paths now succeed for split archives, and `PackageView.sourceType` values returned by the backend resolve their `packages.filter.*` i18n label correctly. - Cascade `package_set_priority` invalidation to `downloadQueries.all()` so the Downloads view/details refresh immediately after a package priority change instead of waiting for a background refetch. - Fix the drag-and-drop fallback: `dataTransfer.getData()` returns `""` for missing keys, not `null`, so `??` never selected the `dragRef` fallback. Switched to an explicit empty-string check so cross-package drags still work in environments that strip custom MIME payloads. - Remove the duplicate generic toast when the rollback also fails: `return` instead of re-throwing so only `moveDownloadRollbackError` is surfaced. - Translate the rollback toast fully into French. --- src/i18n/locales/en.json | 2 +- src/i18n/locales/fr.json | 4 ++-- src/types/package.ts | 2 +- src/views/PackagesView/PackageDialogs.tsx | 2 +- src/views/PackagesView/PackageToolbar.tsx | 2 +- src/views/PackagesView/PackagesView.tsx | 15 ++++++--------- .../PackagesView/__tests__/PackagesView.test.tsx | 2 +- 7 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6276f1ed..295cf457 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -513,7 +513,7 @@ "container": "Container", "playlist": "Playlist", "manual": "Manual", - "split-archive": "Split archive" + "split_archive": "Split archive" }, "row": { "expand": "Expand package", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index eb63d709..2596f79f 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -513,7 +513,7 @@ "container": "Conteneur", "playlist": "Playlist", "manual": "Manuel", - "split-archive": "Archive segmentée" + "split_archive": "Archive segmentée" }, "row": { "expand": "Déplier le paquet", @@ -594,7 +594,7 @@ "moveError": "Échec du déplacement", "moveDownloadSuccess": "Téléchargement déplacé", "moveDownloadError": "Échec du déplacement du téléchargement", - "moveDownloadRollbackError": "Déplacement échoué et rollback échoué — actualisez pour vérifier le contenu du paquet", + "moveDownloadRollbackError": "Le déplacement a échoué et la restauration a aussi échoué — actualisez pour vérifier le contenu du paquet", "bulkPauseSuccess": "Tous les téléchargements mis en pause", "bulkStartSuccess": "Tous les téléchargements relancés", "bulkActionError": "L'action en lot a échoué" diff --git a/src/types/package.ts b/src/types/package.ts index ca1be91e..a5414e89 100644 --- a/src/types/package.ts +++ b/src/types/package.ts @@ -2,7 +2,7 @@ export type PackageSourceType = | 'container' | 'playlist' | 'manual' - | 'split-archive'; + | 'split_archive'; export interface PackageView { id: string; diff --git a/src/views/PackagesView/PackageDialogs.tsx b/src/views/PackagesView/PackageDialogs.tsx index 715fc72c..f45c49b5 100644 --- a/src/views/PackagesView/PackageDialogs.tsx +++ b/src/views/PackagesView/PackageDialogs.tsx @@ -19,7 +19,7 @@ import { } from "@/components/ui/select"; import type { CreatePackageInput, PackageSourceType, PackageView } from "@/types/package"; -const SOURCE_OPTIONS: PackageSourceType[] = ["manual", "playlist", "container", "split-archive"]; +const SOURCE_OPTIONS: PackageSourceType[] = ["manual", "playlist", "container", "split_archive"]; interface AddPackageDialogProps { open: boolean; diff --git a/src/views/PackagesView/PackageToolbar.tsx b/src/views/PackagesView/PackageToolbar.tsx index 8a07c9d2..af5ec263 100644 --- a/src/views/PackagesView/PackageToolbar.tsx +++ b/src/views/PackagesView/PackageToolbar.tsx @@ -9,7 +9,7 @@ const FILTER_ORDER: ReadonlyArray<"all" | PackageSourceType> = [ "container", "playlist", "manual", - "split-archive", + "split_archive", ]; interface PackageToolbarProps { diff --git a/src/views/PackagesView/PackagesView.tsx b/src/views/PackagesView/PackagesView.tsx index 8a37a68d..077a1f4f 100644 --- a/src/views/PackagesView/PackagesView.tsx +++ b/src/views/PackagesView/PackagesView.tsx @@ -112,7 +112,7 @@ export function PackagesView() { const priorityMut = useTauriMutation( "package_set_priority", { - invalidateKeys: INVALIDATE_KEYS, + invalidateKeys: INVALIDATE_KEYS_WITH_DOWNLOADS, errorMessage: () => t("packages.toast.updateError"), }, ); @@ -277,13 +277,10 @@ export function PackagesView() { }, dropDownload: async (toPackageId, e) => { const transfer = e.dataTransfer; - const rawId = - transfer?.getData("application/x-vortex-download") ?? - String(dragRef.current?.downloadId ?? ""); - const fromId = - transfer?.getData("application/x-vortex-source-package") ?? - dragRef.current?.fromPackageId ?? - ""; + const transferId = transfer?.getData("application/x-vortex-download") ?? ""; + const transferFrom = transfer?.getData("application/x-vortex-source-package") ?? ""; + const rawId = transferId !== "" ? transferId : String(dragRef.current?.downloadId ?? ""); + const fromId = transferFrom !== "" ? transferFrom : dragRef.current?.fromPackageId ?? ""; const downloadId = Number(rawId); dragRef.current = null; if (!Number.isFinite(downloadId) || fromId === toPackageId || fromId === "") { @@ -299,7 +296,7 @@ export function PackagesView() { } catch { toast.error(t("packages.toast.moveDownloadRollbackError")); invalidatePackages(); - throw addError; + return; } throw addError; } diff --git a/src/views/PackagesView/__tests__/PackagesView.test.tsx b/src/views/PackagesView/__tests__/PackagesView.test.tsx index 6ae8072d..610a04fc 100644 --- a/src/views/PackagesView/__tests__/PackagesView.test.tsx +++ b/src/views/PackagesView/__tests__/PackagesView.test.tsx @@ -42,7 +42,7 @@ function samplePackages(): PackageView[] { { id: "pkg-2", name: "Backup archive", - sourceType: "split-archive", + sourceType: "split_archive", folderPath: null, autoExtract: true, priority: 7, From a830d7f7e84e403415c1ee93fa3cccdfbae24568 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 30 Apr 2026 10:54:16 +0200 Subject: [PATCH 4/7] fix(ui): trim both sides in rename no-op detection Original package name with edge whitespace would falsely enable submit even when the user made no real edit. Compare `trimmed` against `initialNameRef.current.trim()` so no-op renames are correctly blocked, matching the FolderDialog pattern. --- src/views/PackagesView/PackageDialogs.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/views/PackagesView/PackageDialogs.tsx b/src/views/PackagesView/PackageDialogs.tsx index f45c49b5..4391e9bf 100644 --- a/src/views/PackagesView/PackageDialogs.tsx +++ b/src/views/PackagesView/PackageDialogs.tsx @@ -146,7 +146,8 @@ export function RenamePackageDialog({ pkg, onCancel, onSubmit }: RenamePackageDi }, [open]); const trimmed = name.trim(); - const canSubmit = !submitting && trimmed.length > 0 && trimmed !== initialNameRef.current; + const canSubmit = + !submitting && trimmed.length > 0 && trimmed !== initialNameRef.current.trim(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); From a9487ab75d26260e10d6f554248d51e55b54f8f9 Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:14:04 +0200 Subject: [PATCH 5/7] fix(ui): harden packages drag-and-drop and folder picker - `PackageDownloadRow` now exposes `onDragEnd` and `PackagesView` clears `dragRef` on every drag termination, including drags cancelled or dropped outside the package dropzones. Stops a stale ref from being picked up by an unrelated subsequent drop with empty custom MIME payloads (which would otherwise move the previously dragged download to whichever package the user happens to drop on). - Wrap `FolderDialog.handleBrowse` in a try/catch so a future `onPickFolder` implementation that does not pre-catch its rejection cannot leak an unhandled promise rejection. Current `pickFolder` already returns null on failure, but the dialog stays defensive. --- src/views/PackagesView/PackageDialogs.tsx | 8 ++++++-- src/views/PackagesView/PackageDownloadRow.tsx | 9 ++++++++- src/views/PackagesView/PackageRow.tsx | 2 ++ src/views/PackagesView/PackagesView.tsx | 3 +++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/views/PackagesView/PackageDialogs.tsx b/src/views/PackagesView/PackageDialogs.tsx index 4391e9bf..2835f4dd 100644 --- a/src/views/PackagesView/PackageDialogs.tsx +++ b/src/views/PackagesView/PackageDialogs.tsx @@ -287,8 +287,12 @@ export function FolderDialog({ pkg, onCancel, onPickFolder, onSubmit }: FolderDi !submitting && trimmed.length > 0 && trimmed !== initialFolderRef.current.trim(); const handleBrowse = async () => { - const picked = await onPickFolder(); - if (picked) setFolder(picked); + try { + const picked = await onPickFolder(); + if (picked) setFolder(picked); + } catch { + // picker errors surfaced upstream; keep current value + } }; const handleSubmit = async (e: React.FormEvent) => { diff --git a/src/views/PackagesView/PackageDownloadRow.tsx b/src/views/PackagesView/PackageDownloadRow.tsx index 40cc6473..a097d5b0 100644 --- a/src/views/PackagesView/PackageDownloadRow.tsx +++ b/src/views/PackagesView/PackageDownloadRow.tsx @@ -8,9 +8,15 @@ interface PackageDownloadRowProps { download: DownloadView; packageId: string; onDragStart: (download: DownloadView, fromPackageId: string) => void; + onDragEnd: () => void; } -export function PackageDownloadRow({ download, packageId, onDragStart }: PackageDownloadRowProps) { +export function PackageDownloadRow({ + download, + packageId, + onDragStart, + onDragEnd, +}: PackageDownloadRowProps) { const { t } = useTranslation(); return (
void; startAll: (pkg: PackageView, downloads: DownloadView[]) => void; beginDragDownload: (download: DownloadView, fromPackageId: string) => void; + endDragDownload: () => void; dropDownload: (toPackageId: string, e: React.DragEvent) => void; } @@ -215,6 +216,7 @@ export function PackageRow({ download={d} packageId={pkg.id} onDragStart={actions.beginDragDownload} + onDragEnd={actions.endDragDownload} /> ))}
diff --git a/src/views/PackagesView/PackagesView.tsx b/src/views/PackagesView/PackagesView.tsx index 077a1f4f..2142b42a 100644 --- a/src/views/PackagesView/PackagesView.tsx +++ b/src/views/PackagesView/PackagesView.tsx @@ -275,6 +275,9 @@ export function PackagesView() { if (!Number.isFinite(numericId)) return; dragRef.current = { downloadId: numericId, fromPackageId }; }, + endDragDownload: () => { + dragRef.current = null; + }, dropDownload: async (toPackageId, e) => { const transfer = e.dataTransfer; const transferId = transfer?.getData("application/x-vortex-download") ?? ""; From 16fb47b7864bbe725eb479143012cf1279d359be Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:33:50 +0200 Subject: [PATCH 6/7] fix(ui): refresh package downloads after bulk + preserve raw paths - `pauseMut` and `resumeMut` now invalidate `INVALIDATE_KEYS_WITH_DOWNLOADS` (covers `packageQueries.all()`), so the rows under an expanded package refresh immediately after bulk pause-all/start-all instead of waiting for an unrelated refetch. - `handleDelete` clears `expandedId` when the deleted package was the expanded one. Avoids a stale expansion pointing at a removed row and the unnecessary `package_list_downloads` query that follows. - `AddPackageDialog` and `FolderDialog` now send the raw `folderPath` / `folder` value to the mutation while still using the trimmed copy only to decide whether the field is empty. Paths with intentional edge whitespace are no longer silently rewritten. --- src/views/PackagesView/PackageDialogs.tsx | 4 ++-- src/views/PackagesView/PackagesView.tsx | 9 ++++++--- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/views/PackagesView/PackageDialogs.tsx b/src/views/PackagesView/PackageDialogs.tsx index 2835f4dd..c89931a3 100644 --- a/src/views/PackagesView/PackageDialogs.tsx +++ b/src/views/PackagesView/PackageDialogs.tsx @@ -55,7 +55,7 @@ export function AddPackageDialog({ open, onOpenChange, onSubmit }: AddPackageDia await onSubmit({ name: trimmedName, sourceType, - folderPath: trimmedFolder.length > 0 ? trimmedFolder : undefined, + folderPath: trimmedFolder.length > 0 ? folderPath : undefined, }); onOpenChange(false); } catch { @@ -300,7 +300,7 @@ export function FolderDialog({ pkg, onCancel, onPickFolder, onSubmit }: FolderDi if (!canSubmit) return; setSubmitting(true); try { - await onSubmit(trimmed); + await onSubmit(folder); onCancel(); } catch { // toast surfaced by mutation diff --git a/src/views/PackagesView/PackagesView.tsx b/src/views/PackagesView/PackagesView.tsx index 2142b42a..5c1dc893 100644 --- a/src/views/PackagesView/PackagesView.tsx +++ b/src/views/PackagesView/PackagesView.tsx @@ -144,12 +144,12 @@ export function PackagesView() { ); const pauseMut = useTauriMutation("download_pause", { - invalidateKeys: [downloadQueries.all()] as const, + invalidateKeys: INVALIDATE_KEYS_WITH_DOWNLOADS, silentError: true, }); const resumeMut = useTauriMutation("download_resume", { - invalidateKeys: [downloadQueries.all()] as const, + invalidateKeys: INVALIDATE_KEYS_WITH_DOWNLOADS, silentError: true, }); @@ -204,9 +204,12 @@ export function PackagesView() { async (deleteDownloads: boolean) => { if (!deleting) return; await deleteMut.mutateAsync({ id: deleting.id, deleteDownloads }); + if (expandedId === deleting.id) { + setExpandedId(null); + } toast.success(t("packages.toast.deleteSuccess")); }, - [deleting, deleteMut, t], + [deleting, deleteMut, expandedId, t], ); const pickFolder = useCallback(async () => { From 789c8853445e9b4d0726f4ebd4d88150dd8297ee Mon Sep 17 00:00:00 2001 From: Mathieu Piton <27002047+mpiton@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:44:57 +0200 Subject: [PATCH 7/7] fix(ui): trim folder paths sent to package mutations Reverts the raw-folder-path forwarding from `16fb47b` for folder fields only. Filesystem paths almost never have intentional edge whitespace, and `"/downloads "` would create a package or folder pointing at an invalid location, then surface as broken save/move behaviour or partial-error toasts. Both `AddPackageDialog` (`folderPath`) and `FolderDialog` (`folder`) now forward the trimmed value while the trim continues to gate the empty-check. `name` was already trimmed and remains so. --- src/views/PackagesView/PackageDialogs.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/PackagesView/PackageDialogs.tsx b/src/views/PackagesView/PackageDialogs.tsx index c89931a3..2835f4dd 100644 --- a/src/views/PackagesView/PackageDialogs.tsx +++ b/src/views/PackagesView/PackageDialogs.tsx @@ -55,7 +55,7 @@ export function AddPackageDialog({ open, onOpenChange, onSubmit }: AddPackageDia await onSubmit({ name: trimmedName, sourceType, - folderPath: trimmedFolder.length > 0 ? folderPath : undefined, + folderPath: trimmedFolder.length > 0 ? trimmedFolder : undefined, }); onOpenChange(false); } catch { @@ -300,7 +300,7 @@ export function FolderDialog({ pkg, onCancel, onPickFolder, onSubmit }: FolderDi if (!canSubmit) return; setSubmitting(true); try { - await onSubmit(folder); + await onSubmit(trimmed); onCancel(); } catch { // toast surfaced by mutation