diff --git a/src/components/modals/add.tsx b/src/components/modals/add.tsx index e0eac8b..98d22e5 100644 --- a/src/components/modals/add.tsx +++ b/src/components/modals/add.tsx @@ -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"; @@ -80,8 +80,8 @@ interface AddCommonModalProps extends ModalState { tabsRef: React.RefObject, } -function useCommonProps() { - const location = useTorrentLocation(); +function useCommonProps({ freeSpaceQueryEnabled, spaceNeeded }: UseTorrentLocationOptions) { + const location = useTorrentLocation({ freeSpaceQueryEnabled, spaceNeeded }); const [labels, setLabels] = useState([]); const [start, setStart] = useState(true); const [priority, setPriority] = useState(0); @@ -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) => { @@ -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 && @@ -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(); const existingTorrent = useMemo(() => { @@ -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; @@ -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(); diff --git a/src/components/modals/common.tsx b/src/components/modals/common.tsx index 851955d..8c52884 100644 --- a/src/components/modals/common.tsx +++ b/src/components/modals/common.tsx @@ -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 { @@ -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, + 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(""); + 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); @@ -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 ( - - { props.setPath(e.currentTarget.value); }} - styles={{ root: { flexGrow: 1 } }} - data-autofocus={props.focusPath} - rightSection={ - - - - - - - - - {props.lastPaths.map((path) => ( - { props.setPath(path); }}>{path} - ))} - - - - } /> - {TAURI && } - + { 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={ + + {"Free space: "} + {isLoading + ? + : isError + ? "Unknown" + : bytesToHumanReadableStr(freeSpace["size-bytes"])} + + } + inputContainer={ + (children) => + {children} + {TAURI && } + + } + rightSection={ + + + + + + + + + {props.lastPaths.map((path) => ( + { props.immediateSetPath(path); }}>{path} + ))} + + + + } /> ); } diff --git a/src/components/modals/move.tsx b/src/components/modals/move.tsx index c37cdf8..f173191 100644 --- a/src/components/modals/move.tsx +++ b/src/components/modals/move.tsx @@ -29,8 +29,8 @@ export function MoveModal(props: ModalState) { const serverSelected = useServerSelectedTorrents(); const [moveData, setMoveData] = useState(true); - const location = useTorrentLocation(); - const { setPath } = location; + const location = useTorrentLocation({ freeSpaceQueryEnabled: props.opened }); + const { immediateSetPath } = location; const changeDirectory = useTorrentChangeDirectory(); @@ -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 && - + Enter new location for diff --git a/src/queries.ts b/src/queries.ts index c555194..3e116cb 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -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({ diff --git a/src/rpc/client.ts b/src/rpc/client.ts index aa5d0d0..43f179f 100644 --- a/src/rpc/client.ts +++ b/src/rpc/client.ts @@ -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"; @@ -393,6 +393,15 @@ export class TransmissionClient { throw new Error(`Server returned error: ${response.status} (${response.statusText})`); } } + + async getFreeSpace(path: string): Promise { + const request = { + method: "free-space", + arguments: { path }, + }; + + return (await this._sendRpc(request)).arguments; + } } export const ClientContext = React.createContext( diff --git a/src/rpc/transmission.ts b/src/rpc/transmission.ts index 4169d38..16cad97 100644 --- a/src/rpc/transmission.ts +++ b/src/rpc/transmission.ts @@ -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 +}