Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display free disk space below directory pickers (Add & Move torrent) #127

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions src/components/modals/add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import { Box, Button, Checkbox, Divider, Flex, Group, Menu, SegmentedControl, Text, TextInput } from "@mantine/core";
import React, { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import type { ModalState, LocationData } from "./common";
import type { ModalState, LocationData, UseTorrentLocationOptions } from "./common";
import { HkModal, LimitedNamesList, TorrentLabels, TorrentLocation, useTorrentLocation } from "./common";
import type { PriorityNumberType } from "rpc/transmission";
import { PriorityColors, PriorityStrings } from "rpc/transmission";
Expand Down Expand Up @@ -80,8 +80,8 @@ interface AddCommonModalProps extends ModalState {
tabsRef: React.RefObject<ServerTabsRef>,
}

function useCommonProps() {
const location = useTorrentLocation();
function useCommonProps({ freeSpaceQueryEnabled, spaceNeeded }: UseTorrentLocationOptions) {
const location = useTorrentLocation({ freeSpaceQueryEnabled, spaceNeeded });
const [labels, setLabels] = useState<string[]>([]);
const [start, setStart] = useState<boolean>(true);
const [priority, setPriority] = useState<PriorityNumberType>(0);
Expand Down Expand Up @@ -166,7 +166,10 @@ export function AddMagnet(props: AddCommonModalProps) {
}
}, [serverData, props.serverName, magnetData]);

const common = useCommonProps();
const config = useContext(ConfigContext);
const shouldOpen = !config.values.interface.skipAddDialog || typeof props.uri !== "string";
const renderModal = props.opened && shouldOpen;
const common = useCommonProps({ freeSpaceQueryEnabled: renderModal });
const { close } = props;
const addMutation = useAddTorrent(
useCallback((response: any) => {
Expand Down Expand Up @@ -229,15 +232,13 @@ export function AddMagnet(props: AddCommonModalProps) {
close();
}, [existingTorrent, close, addMutation, magnet, common, mutateAddTrackers, magnetData]);

const config = useContext(ConfigContext);
const shouldOpen = !config.values.interface.skipAddDialog || typeof props.uri !== "string";
useEffect(() => {
if (props.opened && !shouldOpen) {
onAdd();
}
}, [onAdd, props.opened, shouldOpen]);

return <>{props.opened && shouldOpen &&
return <>{renderModal &&
<HkModal opened={true} onClose={close} centered size="lg"
styles={{ title: { flexGrow: 1 } }}
title={<Flex w="100%" align="center" justify="space-between">
Expand Down Expand Up @@ -429,7 +430,6 @@ interface TorrentFileData {
export function AddTorrent(props: AddCommonModalProps) {
const config = useContext(ConfigContext);
const serverData = useServerTorrentData();
const common = useCommonProps();
const [torrentData, setTorrentData] = useState<TorrentFileData[]>();

const existingTorrent = useMemo(() => {
Expand All @@ -450,6 +450,9 @@ export function AddTorrent(props: AddCommonModalProps) {
const fileTree = useMemo(() => new CachedFileTree(torrentData?.[0]?.hash ?? "", -1), [torrentData]);
const [wantedSize, setWantedSize] = useState(0);

const shouldOpen = !config.values.interface.skipAddDialog && torrentData !== undefined;
const common = useCommonProps({ freeSpaceQueryEnabled: shouldOpen, spaceNeeded: wantedSize });

const { data, refetch } = useFileTree("filetreebrief", fileTree);
useEffect(() => {
if (torrentData === undefined) return;
Expand Down Expand Up @@ -550,7 +553,6 @@ export function AddTorrent(props: AddCommonModalProps) {
close();
}, [torrentData, existingTorrent, close, common, addMutation, fileTree, mutateAddTrackers, config]);

const shouldOpen = !config.values.interface.skipAddDialog && torrentData !== undefined;
useEffect(() => {
if (torrentData !== undefined && !shouldOpen) {
onAdd();
Expand Down
143 changes: 101 additions & 42 deletions src/components/modals/common.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,17 @@
import type { ModalProps, MultiSelectValueProps } from "@mantine/core";
import {
Badge, Button, CloseButton, Divider, Group, Loader, Modal, MultiSelect,
Text, TextInput, ActionIcon, Menu, ScrollArea,
Text, TextInput, ActionIcon, Menu, ScrollArea, useMantineTheme, Box,
} from "@mantine/core";
import { ConfigContext, ServerConfigContext } from "config";
import React, { useCallback, useContext, useEffect, useMemo, useState } from "react";
import { pathMapFromServer, pathMapToServer } from "trutil";
import { bytesToHumanReadableStr, pathMapFromServer, pathMapToServer } from "trutil";
import * as Icon from "react-bootstrap-icons";
import { useServerSelectedTorrents, useServerTorrentData } from "rpc/torrent";
import { useHotkeysContext } from "hotkeys";
import { useFreeSpace } from "queries";
import type { Property } from "csstype";
import debounce from "lodash-es/debounce";
const { TAURI, dialogOpen } = await import(/* webpackChunkName: "taurishim" */"taurishim");

export interface ModalState {
Expand Down Expand Up @@ -102,25 +105,51 @@ export function TorrentsNames() {

export interface LocationData {
path: string,
setPath: (s: string) => void,
immediateSetPath: (s: string) => void,
debouncedSetPath: (s: string) => void,
lastPaths: string[],
addPath: (dir: string) => void,
browseHandler: () => void,
inputLabel?: string,
disabled?: boolean,
focusPath?: boolean,
freeSpace: ReturnType<typeof useFreeSpace>,
spaceNeeded?: number,
insufficientSpace: boolean,
errorColor: Property.Color | undefined,
}

export function useTorrentLocation(): LocationData {
export interface UseTorrentLocationOptions {
freeSpaceQueryEnabled: boolean,
spaceNeeded?: number,
}

export function useTorrentLocation({ freeSpaceQueryEnabled, spaceNeeded }: UseTorrentLocationOptions): LocationData {
const config = useContext(ConfigContext);
const serverConfig = useContext(ServerConfigContext);
const lastPaths = useMemo(() => serverConfig.lastSaveDirs, [serverConfig]);

const [path, setPath] = useState<string>("");
const [debouncedPath, setDebouncedPath] = useState(path);

const immediateSetPath = useCallback((newPath: string) => {
setPath(newPath);
setDebouncedPath(newPath);
}, []);

const debouncedSetPath = useMemo(() => {
const debouncedSetter = debounce(setDebouncedPath, 500, { trailing: true, leading: false });
return (newPath: string) => {
setPath(newPath);
debouncedSetter(newPath);
};
}, []);

const freeSpace = useFreeSpace(freeSpaceQueryEnabled, debouncedPath);

useEffect(() => {
setPath(lastPaths.length > 0 ? lastPaths[0] : "");
}, [lastPaths]);
immediateSetPath(lastPaths.length > 0 ? lastPaths[0] : "");
}, [lastPaths, immediateSetPath]);

const browseHandler = useCallback(() => {
const mappedLocation = pathMapFromServer(path, serverConfig);
Expand All @@ -132,52 +161,82 @@ export function useTorrentLocation(): LocationData {
}).then((directory) => {
if (directory === null) return;
const mappedPath = pathMapToServer((directory as string).replace(/\\/g, "/"), serverConfig);
setPath(mappedPath.replace(/\\/g, "/"));
immediateSetPath(mappedPath.replace(/\\/g, "/"));
}).catch(console.error);
}, [serverConfig, path, setPath]);
}, [serverConfig, path, immediateSetPath]);

const addPath = useCallback(
(dir: string) => { config.addSaveDir(serverConfig.name, dir); },
[config, serverConfig.name]);

return { path, setPath, lastPaths, addPath, browseHandler };
const theme = useMantineTheme();
const errorColor = useMemo(
() => theme.fn.variant({ variant: "filled", color: "red" }).background,
[theme]);
const insufficientSpace =
spaceNeeded != null &&
!freeSpace.isLoading &&
!freeSpace.isError &&
freeSpace.data["size-bytes"] < spaceNeeded;

return { path, immediateSetPath, debouncedSetPath, lastPaths, addPath, browseHandler, freeSpace, spaceNeeded, insufficientSpace, errorColor };
}

export function TorrentLocation(props: LocationData) {
const { data: freeSpace, isLoading, isError } = props.freeSpace;
return (
<Group align="flex-end">
<TextInput
value={props.path}
label={props.inputLabel}
disabled={props.disabled}
onChange={(e) => { props.setPath(e.currentTarget.value); }}
styles={{ root: { flexGrow: 1 } }}
data-autofocus={props.focusPath}
rightSection={
<Menu position="left-start" withinPortal
middlewares={{ shift: true, flip: false }} offset={{ mainAxis: -20, crossAxis: 30 }}>
<Menu.Target>
<ActionIcon py="md" disabled={props.disabled}>
<Icon.ClockHistory size="16" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<ScrollArea.Autosize
type="auto"
mah="calc(100vh - 0.5rem)"
miw="30rem"
offsetScrollbars
styles={{ viewport: { paddingBottom: 0 } }}
>
{props.lastPaths.map((path) => (
<Menu.Item key={path} onClick={() => { props.setPath(path); }}>{path}</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
} />
{TAURI && <Button onClick={props.browseHandler} disabled={props.disabled}>Browse</Button>}
</Group>
<TextInput
value={props.path}
label={props.inputLabel}
disabled={props.disabled}
onChange={(e) => { props.debouncedSetPath(e.currentTarget.value); }}
styles={{
wrapper: { flexGrow: 1 },
description: {
color: props.insufficientSpace ? props.errorColor : undefined,
},
}}
data-autofocus={props.focusPath}
inputWrapperOrder={["label", "input", "description"]}
description={
<Text>
{"Free space: "}
{isLoading
? <Box ml="xs" component={Loader} variant="dots" size="xs"/>
: isError
? "Unknown"
: bytesToHumanReadableStr(freeSpace["size-bytes"])}
</Text>
}
inputContainer={
(children) => <Group align="flex-start">
{children}
{TAURI && <Button onClick={props.browseHandler} disabled={props.disabled}>Browse</Button>}
</Group>
}
rightSection={
<Menu position="left-start" withinPortal
middlewares={{ shift: true, flip: false }} offset={{ mainAxis: -20, crossAxis: 30 }}>
<Menu.Target>
<ActionIcon py="md" disabled={props.disabled}>
<Icon.ClockHistory size="16" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<ScrollArea.Autosize
type="auto"
mah="calc(100vh - 0.5rem)"
miw="30rem"
offsetScrollbars
styles={{ viewport: { paddingBottom: 0 } }}
>
{props.lastPaths.map((path) => (
<Menu.Item key={path} onClick={() => { props.immediateSetPath(path); }}>{path}</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
} />
);
}

Expand Down
10 changes: 5 additions & 5 deletions src/components/modals/move.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ export function MoveModal(props: ModalState) {
const serverSelected = useServerSelectedTorrents();
const [moveData, setMoveData] = useState<boolean>(true);

const location = useTorrentLocation();
const { setPath } = location;
const location = useTorrentLocation({ freeSpaceQueryEnabled: props.opened });
const { immediateSetPath } = location;

const changeDirectory = useTorrentChangeDirectory();

Expand Down Expand Up @@ -62,12 +62,12 @@ export function MoveModal(props: ModalState) {
}, [serverData.torrents, serverSelected]);

useEffect(() => {
if (props.opened) setPath(calculateInitialLocation());
}, [props.opened, setPath, calculateInitialLocation]);
if (props.opened) immediateSetPath(calculateInitialLocation());
}, [props.opened, immediateSetPath, calculateInitialLocation]);

return <>
{props.opened &&
<HkModal opened={props.opened} onClose={props.close} title="Move torrents" centered size="lg">
<HkModal opened onClose={props.close} title="Move torrents" centered size="lg">
<Divider my="sm" />
<Text mb="md">Enter new location for</Text>
<TorrentsNames />
Expand Down
13 changes: 13 additions & 0 deletions src/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,19 @@ export function useBandwidthGroups(enabled: boolean) {
});
}

export function useFreeSpace(enabled: boolean, path: string) {
const serverConfig = useContext(ServerConfigContext);
const client = useTransmissionClient();

return useQuery({
queryKey: [serverConfig.name, "free-space", path],
refetchInterval: 5000,
staleTime: 1000,
enabled,
queryFn: useCallback(async () => await client.getFreeSpace(path), [client, path]),
});
}

export function useFileTree(name: string, fileTree: CachedFileTree) {
const initialData = useMemo(() => fileTree.getView(), [fileTree]);
return useQuery({
Expand Down
11 changes: 10 additions & 1 deletion src/rpc/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import { Buffer } from "buffer";

import type { PriorityNumberType, SessionAllFieldsType, SessionStatistics, TorrentFieldsType } from "./transmission";
import type { FreeSpace, PriorityNumberType, SessionAllFieldsType, SessionStatistics, TorrentFieldsType } from "./transmission";
import { SessionAllFields, SessionFields, TorrentAllFields } from "./transmission";
import type { ServerConnection } from "../config";
import type { BandwidthGroup, TorrentBase } from "./torrent";
Expand Down Expand Up @@ -393,6 +393,15 @@ export class TransmissionClient {
throw new Error(`Server returned error: ${response.status} (${response.statusText})`);
}
}

async getFreeSpace(path: string): Promise<FreeSpace> {
const request = {
method: "free-space",
arguments: { path },
};

return (await this._sendRpc(request)).arguments;
}
}

export const ClientContext = React.createContext(
Expand Down
6 changes: 6 additions & 0 deletions src/rpc/transmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,3 +332,9 @@ export const BandwidthGroupFields = [
] as const;

export type BandwidthGroupFieldType = typeof BandwidthGroupFields[number];

export interface FreeSpace {
path: string, // same as the Request argument
["size-bytes"]: number, // the size, in bytes, of the free space in that directory
total_size: number, // the total capacity, in bytes, of that directory
}