diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e23093..c33006a 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.trim(); + + 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 && trimmed !== initialFolderRef.current.trim(); + + const handleBrowse = async () => { + try { + const picked = await onPickFolder(); + if (picked) setFolder(picked); + } catch { + // picker errors surfaced upstream; keep current value + } + }; + + 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 0000000..a097d5b --- /dev/null +++ b/src/views/PackagesView/PackageDownloadRow.tsx @@ -0,0 +1,46 @@ +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; + onDragEnd: () => void; +} + +export function PackageDownloadRow({ + download, + packageId, + onDragStart, + onDragEnd, +}: 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); + }} + onDragEnd={onDragEnd} + 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 0000000..e2344fd --- /dev/null +++ b/src/views/PackagesView/PackageRow.tsx @@ -0,0 +1,226 @@ +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; + endDragDownload: () => 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 0000000..af5ec26 --- /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 0000000..457d63e --- /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 0000000..5c1dc89 --- /dev/null +++ b/src/views/PackagesView/PackagesView.tsx @@ -0,0 +1,393 @@ +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_WITH_DOWNLOADS, + 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: INVALIDATE_KEYS_WITH_DOWNLOADS, + silentError: true, + }); + + const resumeMut = useTauriMutation("download_resume", { + invalidateKeys: INVALIDATE_KEYS_WITH_DOWNLOADS, + 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; + 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], + ); + + const handleDelete = useCallback( + 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, expandedId, 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 }; + }, + endDragDownload: () => { + dragRef.current = null; + }, + dropDownload: async (toPackageId, e) => { + const transfer = e.dataTransfer; + 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 === "") { + return; + } + try { + await removeFromPackageMut.mutateAsync({ packageId: fromId, 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(); + return; + } + throw addError; + } + 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 0000000..610a04f --- /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 0000000..244c96c --- /dev/null +++ b/src/views/PackagesView/index.ts @@ -0,0 +1 @@ +export { PackagesView } from "./PackagesView";