Ability to change the list of files at any time, including through UI (#115)

* Now can update the list of files without pausing/unpausing

* Shrink a few functions

* Reopen write when updating files

* Todos

* opened_file abstraction

* iter_pieces_within iterator

* Simplify iter_pieces_within

* Simplify iter_pieces_within

* Add "iter_file_details"

* temporarily broken: readonly by default

* Live torrent - reopen files

* Reopen files after changing the list

* Now reopening files read only when they are completed

* Fix a bug in opened_file.rs

* update todos

* update help

* Reconnect all peers that are idling

* Add a couple fields to OpenedFile

* Add a couple fields to OpenedFile

* Small cleanups - use the new iterator where possible

* size_of_piece_in_file function

* Updating have

* Include file progress

* Almost nothing

* ugly progress bars

* bad UI, saving

* its not so bad

* Works now

* update progress bar a bit

* Reopen read-only on pause

* Zero bytes isnt too bad! Doesnt break anything

* fix per file progress bars

* progress bar not as ugly anymore?

* ui tweaks

* fix a react bug

* TODO.md update

* Fix js + TODOs

* Compute per-file progress on init

* Fix stats updating live

* Nothing

* Nothing

* cleanup ui a bit

* Nothing

* Final fixes

* Trying to fix rust 1.73

* Sorting filenames

* remove unnecessary indentation

* Remove unnecessary comment
This commit is contained in:
Igor Katson 2024-04-06 09:20:03 +01:00 committed by GitHub
parent d7380217f6
commit 5eb01ac226
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 865 additions and 512 deletions

View file

@ -1,15 +1,18 @@
import { useMemo, useState } from "react";
import { TorrentDetails } from "../api-types";
import { TorrentDetails, TorrentStats } from "../api-types";
import { FormCheckbox } from "./forms/FormCheckbox";
import { CiSquarePlus, CiSquareMinus } from "react-icons/ci";
import { IconButton } from "./buttons/IconButton";
import { formatBytes } from "../helper/formatBytes";
import { ProgressBar } from "./ProgressBar";
import sortBy from "lodash.sortby";
type TorrentFileForCheckbox = {
id: number;
filename: string;
pathComponents: string[];
length: number;
have_bytes: number;
};
type FileTree = {
@ -19,7 +22,10 @@ type FileTree = {
files: TorrentFileForCheckbox[];
};
const newFileTree = (torrentDetails: TorrentDetails): FileTree => {
const newFileTree = (
torrentDetails: TorrentDetails,
stats: TorrentStats | null,
): FileTree => {
const newFileTreeInner = (
name: string,
id: string,
@ -43,8 +49,15 @@ const newFileTree = (torrentDetails: TorrentDetails): FileTree => {
getGroup(file.pathComponents[0]).push(file);
});
directFiles = sortBy(directFiles, (f) => f.filename);
let sortedGroupsByName = sortBy(
Object.entries(groupsByName),
([k, _]) => k,
);
let childId = 0;
for (const [key, value] of Object.entries(groupsByName)) {
for (const [key, value] of sortedGroupsByName) {
groups.push(newFileTreeInner(key, id + "." + childId, value, depth + 1));
childId += 1;
}
@ -65,6 +78,7 @@ const newFileTree = (torrentDetails: TorrentDetails): FileTree => {
filename: file.components[file.components.length - 1],
pathComponents: file.components,
length: file.length,
have_bytes: stats ? stats.file_progress[id] ?? 0 : 0,
};
}),
0,
@ -74,15 +88,21 @@ const newFileTree = (torrentDetails: TorrentDetails): FileTree => {
const FileTreeComponent: React.FC<{
tree: FileTree;
torrentDetails: TorrentDetails;
torrentStats: TorrentStats | null;
selectedFiles: Set<number>;
setSelectedFiles: React.Dispatch<React.SetStateAction<Set<number>>>;
setSelectedFiles: (_: Set<number>) => void;
initialExpanded: boolean;
showProgressBar?: boolean;
disabled?: boolean;
}> = ({
tree,
selectedFiles,
setSelectedFiles,
initialExpanded,
torrentDetails,
torrentStats,
showProgressBar,
disabled,
}) => {
let [expanded, setExpanded] = useState(initialExpanded);
let children = useMemo(() => {
@ -151,6 +171,7 @@ const FileTreeComponent: React.FC<{
{tree.dirs.map((dir) => (
<FileTreeComponent
torrentDetails={torrentDetails}
torrentStats={torrentStats}
key={dir.name}
tree={dir}
selectedFiles={selectedFiles}
@ -160,13 +181,28 @@ const FileTreeComponent: React.FC<{
))}
<div className="pl-1">
{tree.files.map((file) => (
<FormCheckbox
checked={selectedFiles.has(file.id)}
<div
key={file.id}
label={`${file.filename} (${formatBytes(file.length)})`}
name={`file-${file.id}`}
onChange={() => handleToggleFile(file.id)}
></FormCheckbox>
className={`${
showProgressBar
? "grid grid-cols-1 gap-1 items-start lg:grid-cols-2 mb-2 lg:mb-0"
: ""
}`}
>
<FormCheckbox
checked={selectedFiles.has(file.id)}
label={`${file.filename} (${formatBytes(file.length)})`}
name={`file-${file.id}`}
disabled={disabled}
onChange={() => handleToggleFile(file.id)}
></FormCheckbox>
{showProgressBar && (
<ProgressBar
now={(file.have_bytes / file.length) * 100}
variant={file.have_bytes == file.length ? "success" : "info"}
/>
)}
</div>
))}
</div>
</div>
@ -176,20 +212,34 @@ const FileTreeComponent: React.FC<{
export const FileListInput: React.FC<{
torrentDetails: TorrentDetails;
torrentStats: TorrentStats | null;
selectedFiles: Set<number>;
setSelectedFiles: React.Dispatch<React.SetStateAction<Set<number>>>;
}> = ({ torrentDetails, selectedFiles, setSelectedFiles }) => {
let fileTree = useMemo(() => newFileTree(torrentDetails), [torrentDetails]);
setSelectedFiles: (_: Set<number>) => void;
showProgressBar?: boolean;
disabled?: boolean;
}> = ({
torrentDetails,
selectedFiles,
setSelectedFiles,
torrentStats,
showProgressBar,
disabled,
}) => {
let fileTree = useMemo(
() => newFileTree(torrentDetails, torrentStats),
[torrentDetails, torrentStats],
);
return (
<>
<FileTreeComponent
torrentDetails={torrentDetails}
tree={fileTree}
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
initialExpanded={true}
/>
</>
<FileTreeComponent
torrentDetails={torrentDetails}
torrentStats={torrentStats}
tree={fileTree}
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
initialExpanded={true}
showProgressBar={showProgressBar}
disabled={disabled}
/>
);
};

View file

@ -1,9 +1,3 @@
type Props = {
now: number;
label?: string | null;
variant?: "warn" | "info" | "success" | "error";
};
const variantClassNames = {
warn: "bg-amber-500 text-white",
info: "bg-blue-500 text-white",
@ -11,16 +5,25 @@ const variantClassNames = {
error: "bg-red-500 text-white",
};
export const ProgressBar = ({ now, variant, label }: Props) => {
export const ProgressBar: React.FC<{
now: number;
label?: string | null;
variant?: "warn" | "info" | "success" | "error";
classNames?: string;
}> = ({ now, variant, label, classNames }) => {
const progressLabel = label ?? `${now.toFixed(2)}%`;
const variantClassName =
variantClassNames[variant ?? "info"] ?? variantClassNames["info"];
return (
<div className={"w-full bg-gray-200 rounded-full dark:bg-gray-500"}>
<div
className={`w-full bg-gray-200 rounded-full mb-1 dark:bg-gray-500 ${classNames}`}
>
<div
className={`text-xs font-medium transition-all text-center p-0.5 leading-none rounded-full ${variantClassName}`}
className={`text-xs font-medium transition-all text-center leading-none py-0.5 px-2 rounded-full ${variantClassName} ${
now < 1 && "bg-transparent"
}`}
style={{ width: `${now}%` }}
>
{progressLabel}

View file

@ -3,7 +3,7 @@ import {
TorrentDetails,
TorrentStats,
STATE_INITIALIZING,
STATE_LIVE,
ErrorDetails,
} from "../api-types";
import { TorrentActions } from "./buttons/TorrentActions";
import { ProgressBar } from "./ProgressBar";
@ -12,6 +12,10 @@ import { formatBytes } from "../helper/formatBytes";
import { torrentDisplayName } from "../helper/getTorrentDisplayName";
import { getCompletionETA } from "../helper/getCompletionETA";
import { StatusIcon } from "./StatusIcon";
import { FileListInput } from "./FileListInput";
import { useContext, useEffect, useState } from "react";
import { APIContext, RefreshTorrentStatsContext } from "../context";
import { useErrorStore } from "../stores/errorStore";
export const TorrentRow: React.FC<{
id: number;
@ -23,7 +27,11 @@ export const TorrentRow: React.FC<{
const totalBytes = statsResponse?.total_bytes ?? 1;
const progressBytes = statsResponse?.progress_bytes ?? 0;
const finished = statsResponse?.finished || false;
const progressPercentage = error ? 100 : (progressBytes / totalBytes) * 100;
const progressPercentage = error
? 100
: totalBytes == 0
? 100
: (progressBytes / totalBytes) * 100;
const formatPeersString = () => {
let peer_stats = statsResponse?.live?.snapshot.peer_stats;
@ -44,74 +52,133 @@ export const TorrentRow: React.FC<{
);
};
const [selectedFiles, setSelectedFiles] = useState<Set<number>>(new Set());
// Update selected files whenever details are updated.
useEffect(() => {
setSelectedFiles(
new Set<number>(
detailsResponse?.files
.map((f, id) => ({ f, id }))
.filter(({ f }) => f.included)
.map(({ id }) => id) ?? [],
),
);
}, [detailsResponse]);
const API = useContext(APIContext);
const refreshCtx = useContext(RefreshTorrentStatsContext);
const [savingSelectedFiles, setSavingSelectedFiles] = useState(false);
let setCloseableError = useErrorStore((state) => state.setCloseableError);
const updateSelectedFiles = (selectedFiles: Set<number>) => {
setSavingSelectedFiles(true);
API.updateOnlyFiles(id, Array.from(selectedFiles))
.then(
() => {
refreshCtx.refresh();
setCloseableError(null);
},
(e) => {
setCloseableError({
text: "Error configuring torrent",
details: e as ErrorDetails,
});
},
)
.finally(() => setSavingSelectedFiles(false));
};
const [extendedView, setExtendedView] = useState(false);
return (
<section className="flex flex-col sm:flex-row items-center gap-2 border p-2 border-gray-200 rounded-xl shadow-xs hover:drop-shadow-sm dark:bg-slate-800 dark:border-slate-900">
{/* Icon */}
<div className="hidden md:block">{statusIcon("w-10 h-10")}</div>
{/* Name, progress, stats */}
<div className="w-full flex flex-col gap-2">
{detailsResponse && (
<div className="flex items-center gap-2">
<div className="md:hidden">{statusIcon("w-5 h-5")}</div>
<div className="text-left text-lg text-gray-900 text-ellipsis break-all dark:text-slate-200">
{torrentDisplayName(detailsResponse)}
<div className="flex flex-col border p-2 border-gray-200 rounded-xl shadow-xs hover:drop-shadow-sm dark:bg-slate-800 dark:border-slate-900">
<section className="flex flex-col lg:flex-row items-center gap-2">
{/* Icon */}
<div className="hidden md:block">{statusIcon("w-10 h-10")}</div>
{/* Name, progress, stats */}
<div className="w-full flex flex-col gap-2">
{detailsResponse && (
<div className="flex items-center gap-2">
<div className="md:hidden">{statusIcon("w-5 h-5")}</div>
<div className="text-left text-lg text-gray-900 text-ellipsis break-all dark:text-slate-200">
{torrentDisplayName(detailsResponse)}
</div>
</div>
)}
{error ? (
<p className="text-red-500 text-sm">
<strong>Error:</strong> {error}
</p>
) : (
<>
<div>
<ProgressBar
now={progressPercentage}
label={error}
variant={
state == STATE_INITIALIZING
? "warn"
: finished
? "success"
: "info"
}
/>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 sm:flex-wrap items-center text-sm text-nowrap font-medium text-gray-500">
<div className="flex gap-2 items-center">
<GoPeople /> {formatPeersString().toString()}
</div>
<div className="flex gap-2 items-center">
<GoFile />
<div>
{formatBytes(progressBytes)}/{formatBytes(totalBytes)}
</div>
</div>
{statsResponse && (
<>
<div className="flex gap-2 items-center">
<GoClock />
{getCompletionETA(statsResponse)}
</div>
<div className="flex gap-2 items-center">
<Speed statsResponse={statsResponse} />
</div>
</>
)}
</div>
</>
)}
</div>
{/* Actions */}
{statsResponse && (
<div className="">
<TorrentActions
id={id}
statsResponse={statsResponse}
extendedView={extendedView}
setExtendedView={setExtendedView}
/>
</div>
)}
{error ? (
<p className="text-red-500 text-sm">
<strong>Error:</strong> {error}
</p>
) : (
<>
<div>
<ProgressBar
now={progressPercentage}
label={error}
variant={
state == STATE_INITIALIZING
? "warn"
: finished
? "success"
: "info"
}
/>
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2 sm:flex-wrap items-center text-sm text-nowrap font-medium text-gray-500">
<div className="flex gap-2 items-center">
<GoPeople /> {formatPeersString().toString()}
</div>
<div className="flex gap-2 items-center">
<GoFile />
<div>
{formatBytes(progressBytes)}/{formatBytes(totalBytes)}
</div>
</div>
{statsResponse && (
<>
<div className="flex gap-2 items-center">
<GoClock />
{getCompletionETA(statsResponse)}
</div>
<div className="flex gap-2 items-center">
<Speed statsResponse={statsResponse} />
</div>
</>
)}
</div>
</>
)}
</div>
{/* Actions */}
{statsResponse && (
</section>
{/* extended view */}
{detailsResponse && extendedView && (
<div className="">
<TorrentActions
id={id}
detailsResponse={detailsResponse}
statsResponse={statsResponse}
<FileListInput
torrentDetails={detailsResponse}
torrentStats={statsResponse}
selectedFiles={selectedFiles}
setSelectedFiles={updateSelectedFiles}
disabled={savingSelectedFiles}
showProgressBar
/>
</div>
)}
</section>
</div>
);
};

View file

@ -19,7 +19,7 @@ export const IconButton: React.FC<{
const colorClassName = color ? `text-${color}` : "";
return (
<a
className={`block p-1 text-blue-500 flex items-center justify-center ${colorClassName} ${className}`}
className={`p-1 text-blue-500 flex items-center justify-center ${colorClassName} ${className}`}
onClick={onClickStopPropagation}
href="#"
{...otherProps}

View file

@ -1,22 +1,21 @@
import { useContext, useState } from "react";
import { TorrentDetails, TorrentStats } from "../../api-types";
import { TorrentStats } from "../../api-types";
import { APIContext, RefreshTorrentStatsContext } from "../../context";
import { IconButton } from "./IconButton";
import { DeleteTorrentModal } from "../modal/DeleteTorrentModal";
import { TorrentSettingsModal } from "../modal/TorrentSettingsModal";
import { FaCog, FaPause, FaPlay, FaTrash } from "react-icons/fa";
import { useErrorStore } from "../../stores/errorStore";
export const TorrentActions: React.FC<{
id: number;
detailsResponse: TorrentDetails | null;
statsResponse: TorrentStats;
}> = ({ id, detailsResponse, statsResponse }) => {
extendedView: boolean;
setExtendedView: (extendedView: boolean) => void;
}> = ({ id, statsResponse, extendedView, setExtendedView }) => {
let state = statsResponse.state;
let [disabled, setDisabled] = useState<boolean>(false);
let [deleting, setDeleting] = useState<boolean>(false);
let [configuring, setConfiguring] = useState<boolean>(false);
let refreshCtx = useContext(RefreshTorrentStatsContext);
@ -62,10 +61,6 @@ export const TorrentActions: React.FC<{
.finally(() => setDisabled(false));
};
const openConfigureModal = () => {
setConfiguring(true);
};
const startDeleting = () => {
setDisabled(true);
setDeleting(true);
@ -89,7 +84,10 @@ export const TorrentActions: React.FC<{
</IconButton>
)}
{canConfigure && (
<IconButton onClick={openConfigureModal} disabled={disabled}>
<IconButton
onClick={() => setExtendedView(!extendedView)}
disabled={disabled}
>
<FaCog className="hover:text-green-600" />
</IconButton>
)}
@ -97,14 +95,6 @@ export const TorrentActions: React.FC<{
<FaTrash className="hover:text-red-500" />
</IconButton>
<DeleteTorrentModal id={id} show={deleting} onHide={cancelDeleting} />
{detailsResponse && configuring && (
<TorrentSettingsModal
id={id}
show={configuring}
details={detailsResponse}
onHide={() => setConfiguring(false)}
/>
)}
</div>
);
};

View file

@ -8,9 +8,20 @@ export const FormCheckbox: React.FC<{
disabled?: boolean;
inputType?: "checkbox" | "switch";
onChange?: ChangeEventHandler<HTMLInputElement>;
}> = ({ checked, name, disabled, onChange, label, help, inputType }) => {
children?: React.ReactNode;
classNames?: string;
}> = ({
checked,
name,
disabled,
onChange,
label,
help,
inputType,
children,
}) => {
return (
<div className="flex gap-3 items-start">
<div className={`flex gap-3 items-start`}>
<div className="flex">
<input
type={inputType || "checkbox"}
@ -30,6 +41,7 @@ export const FormCheckbox: React.FC<{
</div>
)}
</div>
{children}
</div>
);
};

View file

@ -105,6 +105,7 @@ export const FileSelectionModal = (props: {
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
torrentDetails={listTorrentResponse.details}
torrentStats={null}
/>
</Fieldset>

View file

@ -1,90 +0,0 @@
import React, { useContext, useState } from "react";
import {
AddTorrentResponse,
ErrorDetails,
TorrentDetails,
} from "../../api-types";
import { FileListInput } from "../FileListInput";
import { Modal } from "./Modal";
import { ModalBody } from "./ModalBody";
import { ModalFooter } from "./ModalFooter";
import { Button } from "../buttons/Button";
import { Spinner } from "../Spinner";
import { APIContext, RefreshTorrentStatsContext } from "../../context";
import { ErrorComponent } from "../ErrorComponent";
import { ErrorWithLabel } from "../../stores/errorStore";
export const TorrentSettingsModal: React.FC<{
id: number;
show: boolean;
onHide: () => void;
details: TorrentDetails;
}> = ({ id, show, onHide, details }) => {
let initialSelectedFiles = new Set<number>();
let refreshCtx = useContext(RefreshTorrentStatsContext);
details.files.forEach((f, i) => {
if (f.included) {
initialSelectedFiles.add(i);
}
});
const API = useContext(APIContext);
const [selectedFiles, setSelectedFiles] =
useState<Set<number>>(initialSelectedFiles);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<ErrorWithLabel | null>(null);
const close = () => {
setSelectedFiles(initialSelectedFiles);
onHide();
};
const handleSave = () => {
setSaving(true);
API.updateOnlyFiles(id, Array.from(selectedFiles)).then(
() => {
setSaving(false);
refreshCtx.refresh();
close();
setError(null);
},
(e) => {
setSaving(false);
setError({
text: "Error configuring torrent",
details: e as ErrorDetails,
});
},
);
};
return (
<Modal isOpen={show} onClose={close} title="Configure torrent">
<ModalBody>
<ErrorComponent error={error}></ErrorComponent>
<FileListInput
torrentDetails={details}
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
/>
</ModalBody>
<ModalFooter>
{saving && <Spinner />}
<Button onClick={close} variant="cancel">
Cancel
</Button>
<Button
onClick={handleSave}
variant="primary"
disabled={saving || selectedFiles.size == 0}
>
OK
</Button>
</ModalFooter>
</Modal>
);
};