From 40f245503ebaa1649d62266d980b63e5a9e8b23a Mon Sep 17 00:00:00 2001 From: Egbert Bouman Date: Tue, 1 Oct 2024 13:16:07 +0200 Subject: [PATCH] Show file trees in SaveAs / DownloadDetails components --- .../ui/src/components/ui/simple-table.tsx | 19 ++- src/tribler/ui/src/dialogs/SaveAs.tsx | 143 +++++++++------- src/tribler/ui/src/lib/utils.ts | 68 +++++++- src/tribler/ui/src/models/file.model.tsx | 12 ++ src/tribler/ui/src/pages/Downloads/Files.tsx | 158 ++++++++++-------- 5 files changed, 255 insertions(+), 145 deletions(-) diff --git a/src/tribler/ui/src/components/ui/simple-table.tsx b/src/tribler/ui/src/components/ui/simple-table.tsx index a70658dd08..ac36950de1 100644 --- a/src/tribler/ui/src/components/ui/simple-table.tsx +++ b/src/tribler/ui/src/components/ui/simple-table.tsx @@ -1,7 +1,7 @@ -import { useEffect, useRef, useState } from 'react'; +import { SetStateAction, useEffect, useRef, useState } from 'react'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { getCoreRowModel, useReactTable, flexRender, getFilteredRowModel, getPaginationRowModel } from '@tanstack/react-table'; -import type { ColumnDef, Row, PaginationState, RowSelectionState, ColumnFiltersState } from '@tanstack/react-table'; +import { getCoreRowModel, useReactTable, flexRender, getFilteredRowModel, getPaginationRowModel, getExpandedRowModel } from '@tanstack/react-table'; +import type { ColumnDef, Row, PaginationState, RowSelectionState, ColumnFiltersState, ExpandedState } from '@tanstack/react-table'; import { cn } from '@/lib/utils'; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel } from './select'; import { Button } from './button'; @@ -28,6 +28,7 @@ interface ReactTableProps { allowMultiSelect?: boolean; filters?: { id: string, value: string }[]; maxHeight?: string | number; + expandable?: boolean; } function SimpleTable({ @@ -44,7 +45,8 @@ function SimpleTable({ allowSelectCheckbox, allowMultiSelect, filters, - maxHeight + maxHeight, + expandable }: ReactTableProps) { const [pagination, setPagination] = useState({ pageIndex: pageIndex ?? 0, @@ -52,25 +54,30 @@ function SimpleTable({ }); const [rowSelection, setRowSelection] = useState(initialRowSelection || {}); const [columnFilters, setColumnFilters] = useState(filters || []) + const [expanded, setExpanded] = useState({}); const table = useReactTable({ data, columns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: !!pageSize ? getPaginationRowModel() : undefined, + getExpandedRowModel: expandable ? getExpandedRowModel() : undefined, enableRowSelection: true, pageCount, state: { pagination, rowSelection, columnFilters, + expanded }, getFilteredRowModel: getFilteredRowModel(), onColumnFiltersChange: setColumnFilters, onPaginationChange: setPagination, - onRowSelectionChange: (arg) => { + onRowSelectionChange: (arg: SetStateAction) => { if (allowSelect || allowSelectCheckbox || allowMultiSelect) setRowSelection(arg); }, + onExpandedChange: setExpanded, + getSubRows: (row: any) => row?.subRows, }); const { t } = useTranslation(); @@ -99,7 +106,7 @@ function SimpleTable({ // For some reason the ScrollArea scrollbar is only shown when it's set to a specific height. // So, we wrap it in a parent div, monitor its size, and set the height of the table accordingly. const parentRef = useRef(null); - const parentRect = (!maxHeight) ? useResizeObserver({ref: parentRef}) : undefined; + const parentRect = (!maxHeight) ? useResizeObserver({ ref: parentRef }) : undefined; return ( <> diff --git a/src/tribler/ui/src/dialogs/SaveAs.tsx b/src/tribler/ui/src/dialogs/SaveAs.tsx index 31aa460b16..5d7fbef659 100644 --- a/src/tribler/ui/src/dialogs/SaveAs.tsx +++ b/src/tribler/ui/src/dialogs/SaveAs.tsx @@ -1,74 +1,73 @@ import SimpleTable from "@/components/ui/simple-table"; -import { useEffect, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import toast from 'react-hot-toast'; import { triblerService } from "@/services/tribler.service"; import { isErrorDict } from "@/services/reporting"; -import { formatBytes, getFilesFromMetainfo, getRowSelection, translateHeader } from "@/lib/utils"; +import { filesToTree, fixTreeProps, formatBytes, getFilesFromMetainfo, getRowSelection, translateHeader } from "@/lib/utils"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { DialogProps } from "@radix-ui/react-dialog"; import { JSX } from "react/jsx-runtime"; import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; -import { ColumnDef } from "@tanstack/react-table"; +import { ColumnDef, Row } from "@tanstack/react-table"; import { useNavigate } from "react-router-dom"; import { Settings } from "@/models/settings.model"; import { useTranslation } from "react-i18next"; import { TFunction } from 'i18next'; import { PathInput } from "@/components/path-input"; +import { ChevronDown, ChevronRight } from "lucide-react"; +import { CheckedState } from "@radix-ui/react-checkbox"; +import { FileTreeItem } from "@/models/file.model"; function startDownloadCallback(response: any, t: TFunction) { // We have to receive a translation function. Otherwise, we violate React's hook scoping. if (response === undefined) { toast.error(`${t("ToastErrorDownloadStart")} ${t("ToastErrorGenNetworkErr")}`); - } else if (isErrorDict(response)){ + } else if (isErrorDict(response)) { toast.error(`${t("ToastErrorDownloadStart")} ${response.error}`); } } -const fileColumns: ColumnDef[] = [ +const getFileColumns = ({ onSelectedFiles }: { onSelectedFiles: (row: Row) => void }): ColumnDef[] => [ { - id: "select", - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select row" - /> - ), - enableSorting: false, - enableHiding: false, - }, - { - accessorKey: "path", + accessorKey: "name", header: translateHeader('Name'), + cell: ({ row }) => { + return ( +
+ {row.original.subRows && row.original.subRows.length > 0 && ( + + )} + {row.original.name} +
+ ) + } }, { - accessorKey: "length", + accessorKey: "size", header: translateHeader('Size'), cell: ({ row }) => { - return {formatBytes(row.original.length)} + return ( +
+ onSelectedFiles(row)}> + {formatBytes(row.original.size)} +
+ ) }, }, ] -interface TorrentFile { - path: string; - length: number; - included?: boolean; -} - interface Params { destination: string anon_hops: number @@ -81,6 +80,28 @@ interface SaveAsProps { torrent?: File; } +const toggleTree = (tree: FileTreeItem, included: boolean = true) => { + if (tree.subRows && tree.subRows.length) { + for (const item of tree.subRows) { + toggleTree(item, included); + } + } + tree.included = included; +} + +const getSelectedFiles = (tree: FileTreeItem, included: boolean = true) => { + const selectedFiles: number[] = []; + if (tree.subRows && tree.subRows.length) { + for (const item of tree.subRows) { + for (const i of getSelectedFiles(item, included)) + selectedFiles.push(i); + } + } + else if (tree.included === included) + selectedFiles.push(tree.index); + return selectedFiles; +} + export default function SaveAs(props: SaveAsProps & JSX.IntrinsicAttributes & DialogProps) { let { uri, torrent } = props; @@ -89,8 +110,20 @@ export default function SaveAs(props: SaveAsProps & JSX.IntrinsicAttributes & Di const [settings, setSettings] = useState(); const [error, setError] = useState(); const [exists, setExists] = useState(false); - const [selectedFiles, setSelectedFiles] = useState([]); - const [files, setFiles] = useState([]); + const [files, setFiles] = useState([]); + + + function OnSelectedFilesChange(row: Row) { + toggleTree(row.original, !row.original.included); + fixTreeProps(files[0]); + setFiles([...files]); + setParams({ + ...params, + selected_files: getSelectedFiles(files[0]), + }); + } + + const fileColumns = useMemo(() => getFileColumns({ onSelectedFiles: OnSelectedFilesChange }), [OnSelectedFilesChange]); const [params, setParams] = useState({ destination: '', anon_hops: 0, @@ -110,7 +143,7 @@ export default function SaveAs(props: SaveAsProps & JSX.IntrinsicAttributes & Di if (newSettings === undefined) { setError(`${t("ToastErrorGetSettings")} ${t("ToastErrorGenNetworkErr")}`); return; - } else if (isErrorDict(newSettings)){ + } else if (isErrorDict(newSettings)) { setError(`${t("ToastErrorGetSettings")} ${newSettings.error}`); return; } @@ -138,38 +171,26 @@ export default function SaveAs(props: SaveAsProps & JSX.IntrinsicAttributes & Di } else if (isErrorDict(response)) { setError(`t("ToastErrorGetMetainfo")} ${response.error}`); } else if (response) { - setFiles(getFilesFromMetainfo(response.metainfo)); + const info = getFilesFromMetainfo(response.metainfo); + var files = info.files; + files.sort((f1: any, f2: any) => f1.name > f2.name ? 1 : -1); + files = filesToTree(files, info.name); + setFiles(files); + setParams((prev) => ({ ...prev, selected_files: getSelectedFiles(files[0]) })); setExists(!!response.download_exists); } } reload(); }, [uri, torrent]); - useEffect(() => { - let indexes = []; - for (let i = 0; i < selectedFiles.length; i++) { - for (let j = 0; j < files.length; j++) { - if (selectedFiles[i].path === files[j].path) { - indexes.push(j); - break; - } - } - } - - setParams({ - ...params, - selected_files: indexes, - }) - }, [selectedFiles]); - function OnDownloadClicked() { if (!settings) return; if (torrent) { - triblerService.startDownloadFromFile(torrent, params).then((response) => {startDownloadCallback(response, t)}); + triblerService.startDownloadFromFile(torrent, params).then((response) => { startDownloadCallback(response, t) }); } else if (uri) { - triblerService.startDownload(uri, params).then((response) => {startDownloadCallback(response, t)}); + triblerService.startDownload(uri, params).then((response) => { startDownloadCallback(response, t) }); } if (props.onOpenChange) { @@ -209,8 +230,8 @@ export default function SaveAs(props: SaveAsProps & JSX.IntrinsicAttributes & Di data={files} columns={fileColumns} allowSelectCheckbox={true} - onSelectedRowsChange={setSelectedFiles} initialRowSelection={getRowSelection(files, () => true)} + expandable={true} maxHeight={200} /> {exists && {t('DownloadExists')}} @@ -265,7 +286,7 @@ export default function SaveAs(props: SaveAsProps & JSX.IntrinsicAttributes & Di variant="outline" type="submit" onClick={() => OnDownloadClicked()} - disabled={exists || (files.length !== 0 && selectedFiles.length === 0)}> + disabled={exists || (files.length !== 0 && params.selected_files.length === 0)}> {t('Download')} diff --git a/src/tribler/ui/src/lib/utils.ts b/src/tribler/ui/src/lib/utils.ts index 24bf59ebfe..e6400d5bae 100644 --- a/src/tribler/ui/src/lib/utils.ts +++ b/src/tribler/ui/src/lib/utils.ts @@ -10,6 +10,8 @@ import zh from 'javascript-time-ago/locale/zh' import Cookies from "js-cookie"; import { useTranslation } from "react-i18next"; import { triblerService } from "@/services/tribler.service"; +import { FileTreeItem } from "@/models/file.model"; +import { CheckedState } from "@radix-ui/react-checkbox"; TimeAgo.setDefaultLocale(en.locale) TimeAgo.addLocale(en) @@ -38,11 +40,15 @@ export function unhexlify(input: string) { export function getFilesFromMetainfo(metainfo: string) { const info = JSON.parse(unhexlify(metainfo))?.info || {}; if (!info?.files) { - return [{ length: info.length, path: info.name }]; + return { + files: [{ size: info.length, name: info.name, index: 0 }], + name: info.name + }; } - return info.files.map((file: any) => ( - { length: file.length, path: file.path.join('\\') } - )); + return { + files: info.files.map((file: any, i: number) => ({ size: file.length, name: file.path.join('\\'), index: i })), + name: info.name + }; } export function getMagnetLink(infohash: string, name: string): string { @@ -152,3 +158,57 @@ export function filterDuplicates(data: any[], key: string) { return !duplicate; }); } + +export const filesToTree = (files: FileTreeItem[], defaultName = "root", separator: string = '\\') => { + if (files.length <= 1) { + if (files.length == 1 && files[0].included == undefined) + files[0].included = true; + return files; + } + + let result: any[] = []; + let level = { result }; + + files.forEach(file => { + file.name.split(separator).reduce((r: any, name, i, a) => { + if (!r[name]) { + r[name] = { result: [] }; + r.result.push({ included: true, ...file, name, subRows: r[name].result }) + } + return r[name]; + }, level) + }) + + files = [{ + index: -1, + name: defaultName, + size: 1, + progress: 1, + included: true, + subRows: result, + }]; + fixTreeProps(files[0]); + return files; +} + +export const fixTreeProps = (tree: FileTreeItem): { size: number, downloaded: number, included: CheckedState | undefined } => { + if (tree.subRows && tree.subRows.length) { + tree.size = tree.downloaded = 0; + tree.included = undefined; + for (const item of tree.subRows) { + const { size, downloaded, included } = fixTreeProps(item); + tree.size += size; + tree.downloaded += downloaded; + if (tree.included !== undefined) + tree.included = tree.included == included ? included : 'indeterminate'; + else + tree.included = included; + } + tree.progress = (tree.downloaded || 0) / tree.size; + } + return { + size: tree.size, + downloaded: tree.size * (tree.progress || 0), + included: tree.included + }; +} diff --git a/src/tribler/ui/src/models/file.model.tsx b/src/tribler/ui/src/models/file.model.tsx index cd2eba81ce..dd9aee08b7 100644 --- a/src/tribler/ui/src/models/file.model.tsx +++ b/src/tribler/ui/src/models/file.model.tsx @@ -1,5 +1,7 @@ // For compile-time type checking and code completion +import { CheckedState } from "@radix-ui/react-checkbox"; + export interface File { index: number; name: string; @@ -7,3 +9,13 @@ export interface File { included: boolean; progress: number; } + +export interface FileTreeItem { + index: number; + name: string; + size: number; + downloaded?: number; + progress?: number; + included?: CheckedState; + subRows?: FileTreeItem[]; +} diff --git a/src/tribler/ui/src/pages/Downloads/Files.tsx b/src/tribler/ui/src/pages/Downloads/Files.tsx index 5033f93886..3580faf9ee 100644 --- a/src/tribler/ui/src/pages/Downloads/Files.tsx +++ b/src/tribler/ui/src/pages/Downloads/Files.tsx @@ -1,66 +1,79 @@ import toast from 'react-hot-toast'; -import { ColumnDef } from "@tanstack/react-table"; -import { File } from "@/models/file.model"; +import { ColumnDef, Row } from "@tanstack/react-table"; +import { File, FileTreeItem } from "@/models/file.model"; import { Download } from "@/models/download.model"; -import { Dispatch, MutableRefObject, SetStateAction, useEffect, useRef, useState } from "react"; +import { Dispatch, MutableRefObject, SetStateAction, useEffect, useMemo, useRef, useState } from "react"; import { isErrorDict } from "@/services/reporting"; import { triblerService } from "@/services/tribler.service"; import SimpleTable from "@/components/ui/simple-table"; +import { ChevronDown, ChevronRight } from "lucide-react"; import { Checkbox } from "@/components/ui/checkbox"; -import { formatBytes, getRowSelection, translateHeader } from "@/lib/utils"; +import { filesToTree, formatBytes } from "@/lib/utils"; import { useTranslation } from "react-i18next"; - -const fileColumns: ColumnDef[] = [ - { - id: "select", - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> - ), - cell: ({ row }) => ( - { - console.log(value); - row.toggleSelected(!!value) - }} - aria-label="Select row" - /> - ), - enableSorting: false, - enableHiding: false, - }, +const getFileColumns = ({ onSelectedFiles }: { onSelectedFiles: (row: Row) => void }): ColumnDef[] => [ { - accessorKey: "name", - header: translateHeader('Name'), + header: "Path", + accessorKey: "path", + cell: ({ row }) => { + return ( +
+ {row.original.subRows && row.original.subRows.length > 0 && ( + + )} + {row.original.name} +
+ ) + } }, { + header: "Size", accessorKey: "size", - header: translateHeader('Size'), cell: ({ row }) => { - return {formatBytes(row.original.size)} + return ( +
+ onSelectedFiles(row)}> + {formatBytes(row.original.size)} +
+ ) }, }, { + header: "Progress", accessorKey: "progress", - header: translateHeader('Progress'), cell: ({ row }) => { - return {(row.original.progress * 100).toFixed(1)}% + return {((row.original.progress || 0) * 100).toFixed(1)}% }, }, -] +]; -async function updateFiles(setFiles: Dispatch>, infohash: string, initialized: MutableRefObject) { - const response = await triblerService.getDownloadFiles(infohash); +const getSelectedFiles = (tree: FileTreeItem, included: boolean = true) => { + const selectedFiles: number[] = []; + if (tree.subRows && tree.subRows.length) { + for (const item of tree.subRows) { + for (const i of getSelectedFiles(item, included)) + selectedFiles.push(i); + } + } + else if (tree.included === included) + selectedFiles.push(tree.index); + return selectedFiles; +} + +async function updateFiles(setFiles: Dispatch>, download: Download, initialized: MutableRefObject) { + const response = await triblerService.getDownloadFiles(download.infohash); if (response !== undefined && !isErrorDict(response)) { - setFiles(response); + const files = filesToTree(response, download.name, '/'); + setFiles(files); } else { // Don't bother the user on error, just try again later. initialized.current = false; @@ -69,36 +82,28 @@ async function updateFiles(setFiles: Dispatch>, infohash: export default function Files({ download }: { download: Download }) { const { t } = useTranslation(); - const [files, setFiles] = useState([]); + const [files, setFiles] = useState([]); const initialized = useRef(false) - function OnSelectedFilesChange(selectedFiles: File[]) { - let shouldUpdate = false; - let selectIndices: number[] = []; + function OnSelectedFilesChange(row: Row) { + // Are we including or excluding files? + const shouldInclude = row.original.included == false; + // Get all indices that need toggling + const toggleIndices = getSelectedFiles(row.original, !shouldInclude); + const currentIndcices = getSelectedFiles(files[0]); + if (shouldInclude) + var selectedIndices = [...new Set(currentIndcices).union(new Set(toggleIndices))]; + else + var selectedIndices = [...new Set(currentIndcices).difference(new Set(toggleIndices))]; - for (let file of files) { - let otherFile = undefined - for (let f of selectedFiles) { - if (f.index === file.index) { - otherFile = f; - selectIndices.push(otherFile.index); - break; - } + triblerService.setDownloadFiles(download.infohash, selectedIndices).then((response) => { + if (response === undefined) { + toast.error(`${t("ToastErrorDownloadSetFiles")} ${t("ToastErrorGenNetworkErr")}`); + } else if (isErrorDict(response)) { + toast.error(`${t("ToastErrorDownloadSetFiles")} ${response.error}`); } - - const otherIncluded = !!otherFile; - shouldUpdate = shouldUpdate || (file.included !== otherIncluded); - file.included = otherIncluded; - } - - if (shouldUpdate) - triblerService.setDownloadFiles(download.infohash, selectIndices).then((response) => { - if (response === undefined) { - toast.error(`${t("ToastErrorDownloadSetFiles")} ${t("ToastErrorGenNetworkErr")}`); - } else if (isErrorDict(response)){ - toast.error(`${t("ToastErrorDownloadSetFiles")} ${response.error}`); - } - }); + }); + updateFiles(setFiles, download, initialized); } useEffect(() => { @@ -107,20 +112,25 @@ export default function Files({ download }: { download: Download }) { return; } initialized.current = true; - updateFiles(setFiles, download.infohash, initialized); + updateFiles(setFiles, download, initialized); }, []); - // We'll wait until the API call returns so the selection gets set by initialRowSelection + useEffect(() => { + if (download.status_code === 3) + updateFiles(setFiles, download, initialized); + }, [download]); + + const fileColumns = useMemo(() => getFileColumns({ onSelectedFiles: OnSelectedFilesChange }), [OnSelectedFilesChange]); + + // The API call may not be finished yet or the download is still getting metainfo. if (files.length === 0) - return <>; + return No files available; return file.included)} + expandable={true} + pageSize={50} maxHeight={'none'} /> }