Skip to content

Commit

Permalink
Show file trees in SaveAs / DownloadDetails components
Browse files Browse the repository at this point in the history
  • Loading branch information
egbertbouman committed Oct 4, 2024
1 parent 7d4a25e commit 40f2455
Show file tree
Hide file tree
Showing 5 changed files with 255 additions and 145 deletions.
19 changes: 13 additions & 6 deletions src/tribler/ui/src/components/ui/simple-table.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -28,6 +28,7 @@ interface ReactTableProps<T extends object> {
allowMultiSelect?: boolean;
filters?: { id: string, value: string }[];
maxHeight?: string | number;
expandable?: boolean;
}

function SimpleTable<T extends object>({
Expand All @@ -44,33 +45,39 @@ function SimpleTable<T extends object>({
allowSelectCheckbox,
allowMultiSelect,
filters,
maxHeight
maxHeight,
expandable
}: ReactTableProps<T>) {
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: pageIndex ?? 0,
pageSize: pageSize ?? 20,
});
const [rowSelection, setRowSelection] = useState<RowSelectionState>(initialRowSelection || {});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(filters || [])
const [expanded, setExpanded] = useState<ExpandedState>({});

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<RowSelectionState>) => {
if (allowSelect || allowSelectCheckbox || allowMultiSelect) setRowSelection(arg);
},
onExpandedChange: setExpanded,
getSubRows: (row: any) => row?.subRows,
});

const { t } = useTranslation();
Expand Down Expand Up @@ -99,7 +106,7 @@ function SimpleTable<T extends object>({
// 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<HTMLTableElement>(null);
const parentRect = (!maxHeight) ? useResizeObserver({ref: parentRef}) : undefined;
const parentRect = (!maxHeight) ? useResizeObserver({ ref: parentRef }) : undefined;

return (
<>
Expand Down
143 changes: 82 additions & 61 deletions src/tribler/ui/src/dialogs/SaveAs.tsx
Original file line number Diff line number Diff line change
@@ -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<TorrentFile>[] = [
const getFileColumns = ({ onSelectedFiles }: { onSelectedFiles: (row: Row<FileTreeItem>) => void }): ColumnDef<FileTreeItem>[] => [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "path",
accessorKey: "name",
header: translateHeader('Name'),
cell: ({ row }) => {
return (
<div
className="flex text-start items-center"
style={{
paddingLeft: `${row.depth * 2}rem`
}}
>
{row.original.subRows && row.original.subRows.length > 0 && (
<button onClick={row.getToggleExpandedHandler()}>
{row.getIsExpanded()
? <ChevronDown size="16" color="#777"></ChevronDown>
: <ChevronRight size="16" color="#777"></ChevronRight>}
</button>
)}
{row.original.name}
</div>
)
}
},
{
accessorKey: "length",
accessorKey: "size",
header: translateHeader('Size'),
cell: ({ row }) => {
return <span className="whitespace-nowrap">{formatBytes(row.original.length)}</span>
return (
<div className='flex items-center'>
<Checkbox className='mr-2' checked={row.original.included} onCheckedChange={() => onSelectedFiles(row)}></Checkbox>
<span>{formatBytes(row.original.size)}</span>
</div>
)
},
},
]

interface TorrentFile {
path: string;
length: number;
included?: boolean;
}

interface Params {
destination: string
anon_hops: number
Expand All @@ -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;

Expand All @@ -89,8 +110,20 @@ export default function SaveAs(props: SaveAsProps & JSX.IntrinsicAttributes & Di
const [settings, setSettings] = useState<Settings | undefined>();
const [error, setError] = useState<string | undefined>();
const [exists, setExists] = useState<boolean>(false);
const [selectedFiles, setSelectedFiles] = useState<TorrentFile[]>([]);
const [files, setFiles] = useState<TorrentFile[]>([]);
const [files, setFiles] = useState<FileTreeItem[]>([]);


function OnSelectedFilesChange(row: Row<FileTreeItem>) {
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<Params>({
destination: '',
anon_hops: 0,
Expand All @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 && <span className="text-center text-tribler text-sm">{t('DownloadExists')}</span>}
</>
Expand Down Expand Up @@ -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')}
</Button>
<DialogClose asChild>
Expand Down
68 changes: 64 additions & 4 deletions src/tribler/ui/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
};
}
Loading

0 comments on commit 40f2455

Please sign in to comment.