diff --git a/app/Http/Controllers/Api/Client/Servers/FileController.php b/app/Http/Controllers/Api/Client/Servers/FileController.php index 470adc2ce..b6e2eed56 100644 --- a/app/Http/Controllers/Api/Client/Servers/FileController.php +++ b/app/Http/Controllers/Api/Client/Servers/FileController.php @@ -216,7 +216,7 @@ class FileController extends ClientApiController } /** - * Deletes a file or folder from the server. + * Deletes files or folders for the server in the given root directory. * * @param \Pterodactyl\Http\Requests\Api\Client\Servers\Files\DeleteFileRequest $request * @param \Pterodactyl\Models\Server $server @@ -224,9 +224,10 @@ class FileController extends ClientApiController */ public function delete(DeleteFileRequest $request, Server $server): JsonResponse { - $this->fileRepository - ->setServer($server) - ->deleteFile($request->input('location')); + $this->fileRepository->setServer($server) + ->deleteFiles( + $request->input('root'), $request->input('files') + ); return new JsonResponse([], Response::HTTP_NO_CONTENT); } diff --git a/app/Http/Requests/Api/Client/Servers/Files/DeleteFileRequest.php b/app/Http/Requests/Api/Client/Servers/Files/DeleteFileRequest.php index dcf1d2a95..7f5ccbf72 100644 --- a/app/Http/Requests/Api/Client/Servers/Files/DeleteFileRequest.php +++ b/app/Http/Requests/Api/Client/Servers/Files/DeleteFileRequest.php @@ -22,7 +22,9 @@ class DeleteFileRequest extends ClientApiRequest implements ClientPermissionsReq public function rules(): array { return [ - 'location' => 'required|string', + 'root' => 'required|nullable|string', + 'files' => 'required|array', + 'files.*' => 'string', ]; } } diff --git a/app/Repositories/Wings/DaemonFileRepository.php b/app/Repositories/Wings/DaemonFileRepository.php index e69af982f..3f506d149 100644 --- a/app/Repositories/Wings/DaemonFileRepository.php +++ b/app/Repositories/Wings/DaemonFileRepository.php @@ -151,10 +151,11 @@ class DaemonFileRepository extends DaemonRepository /** * Delete a file or folder for the server. * - * @param string $location + * @param string|null $root + * @param array $files * @return \Psr\Http\Message\ResponseInterface */ - public function deleteFile(string $location): ResponseInterface + public function deleteFiles(?string $root, array $files): ResponseInterface { Assert::isInstanceOf($this->server, Server::class); @@ -162,7 +163,8 @@ class DaemonFileRepository extends DaemonRepository sprintf('/api/servers/%s/files/delete', $this->server->uuid), [ 'json' => [ - 'location' => $location, + 'root' => $root, + 'files' => $files, ], ] ); diff --git a/resources/scripts/api/server/files/deleteFile.ts b/resources/scripts/api/server/files/deleteFiles.ts similarity index 65% rename from resources/scripts/api/server/files/deleteFile.ts rename to resources/scripts/api/server/files/deleteFiles.ts index cb8a59e02..1250463ed 100644 --- a/resources/scripts/api/server/files/deleteFile.ts +++ b/resources/scripts/api/server/files/deleteFiles.ts @@ -1,8 +1,8 @@ import http from '@/api/http'; -export default (uuid: string, location: string): Promise => { +export default (uuid: string, directory: string, files: string[]): Promise => { return new Promise((resolve, reject) => { - http.post(`/api/client/servers/${uuid}/files/delete`, { location }) + http.post(`/api/client/servers/${uuid}/files/delete`, { root: directory, files }) .then(() => resolve()) .catch(reject); }); diff --git a/resources/scripts/components/elements/Checkbox.tsx b/resources/scripts/components/elements/Checkbox.tsx index ae7548dcf..790536489 100644 --- a/resources/scripts/components/elements/Checkbox.tsx +++ b/resources/scripts/components/elements/Checkbox.tsx @@ -5,13 +5,14 @@ import Input from '@/components/elements/Input'; interface Props { name: string; value: string; + className?: string; } type OmitFields = 'ref' | 'name' | 'value' | 'type' | 'checked' | 'onClick' | 'onChange'; type InputProps = Omit; -const Checkbox = ({ name, value, ...props }: Props & InputProps) => ( +const Checkbox = ({ name, value, className, ...props }: Props & InputProps) => ( {({ field, form }: FieldProps) => { if (!Array.isArray(field.value)) { @@ -24,6 +25,7 @@ const Checkbox = ({ name, value, ...props }: Props & InputProps) => ( form.setFieldTouched(field.name, true)} diff --git a/resources/scripts/components/server/files/FileDropdownMenu.tsx b/resources/scripts/components/server/files/FileDropdownMenu.tsx index 5d4e14fdc..e372ac09d 100644 --- a/resources/scripts/components/server/files/FileDropdownMenu.tsx +++ b/resources/scripts/components/server/files/FileDropdownMenu.tsx @@ -13,7 +13,7 @@ import { import RenameFileModal from '@/components/server/files/RenameFileModal'; import { ServerContext } from '@/state/server'; import { join } from 'path'; -import deleteFile from '@/api/server/files/deleteFile'; +import deleteFiles from '@/api/server/files/deleteFiles'; import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; import copyFile from '@/api/server/files/copyFile'; import Can from '@/components/elements/Can'; @@ -71,7 +71,7 @@ export default ({ file }: { file: FileObject }) => { // If the delete actually fails, we'll fetch the current directory contents again automatically. mutate(files => files.filter(f => f.uuid !== file.uuid), false); - deleteFile(uuid, join(directory, file.name)).catch(error => { + deleteFiles(uuid, directory, [ file.name ]).catch(error => { mutate(); clearAndAddHttpError({ key: 'files', error }); }); diff --git a/resources/scripts/components/server/files/FileManagerContainer.tsx b/resources/scripts/components/server/files/FileManagerContainer.tsx index eeed24078..8ff6fe603 100644 --- a/resources/scripts/components/server/files/FileManagerContainer.tsx +++ b/resources/scripts/components/server/files/FileManagerContainer.tsx @@ -15,6 +15,8 @@ import Button from '@/components/elements/Button'; import useServer from '@/plugins/useServer'; import { ServerContext } from '@/state/server'; import useFileManagerSwr from '@/plugins/useFileManagerSwr'; +import { Form, Formik } from 'formik'; +import MassActionsBar from '@/components/server/files/MassActionsBar'; const sortFiles = (files: FileObject[]): FileObject[] => { return files.sort((a, b) => a.name.localeCompare(b.name)) @@ -55,23 +57,29 @@ export default () => {

: - -
- {files.length > 250 && -
-

- This directory is too large to display in the browser, - limiting the output to the first 250 files. -

-
- } - { - sortFiles(files.slice(0, 250)).map(file => ( - - )) - } -
-
+
+ undefined} + initialValues={{ selectedFiles: [] }} + > +
+ {files.length > 250 && +
+

+ This directory is too large to display in the browser, + limiting the output to the first 250 files. +

+
+ } + { + sortFiles(files.slice(0, 250)).map(file => ( + + )) + } + + +
+
} diff --git a/resources/scripts/components/server/files/FileObjectRow.tsx b/resources/scripts/components/server/files/FileObjectRow.tsx index 2f097e1b9..13b329d2b 100644 --- a/resources/scripts/components/server/files/FileObjectRow.tsx +++ b/resources/scripts/components/server/files/FileObjectRow.tsx @@ -10,11 +10,22 @@ import { NavLink, useHistory, useRouteMatch } from 'react-router-dom'; import tw from 'twin.macro'; import isEqual from 'react-fast-compare'; import styled from 'styled-components/macro'; +import FormikCheckbox from '@/components/elements/Checkbox'; const Row = styled.div` ${tw`flex bg-neutral-700 rounded-sm mb-px text-sm hover:text-neutral-100 cursor-pointer items-center no-underline hover:bg-neutral-600`}; `; +const Checkbox = styled(FormikCheckbox)` + && { + ${tw`border-neutral-500`}; + + &:not(:checked) { + ${tw`hover:border-neutral-300`}; + } + } +`; + const FileObjectRow = ({ file }: { file: FileObject }) => { const directory = ServerContext.useStoreState(state => state.files.directory); const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory); @@ -44,12 +55,15 @@ const FileObjectRow = ({ file }: { file: FileObject }) => { window.dispatchEvent(new CustomEvent(`pterodactyl:files:ctx:${file.uuid}`, { detail: e.clientX })); }} > + -
+
{file.isFile ? : diff --git a/resources/scripts/components/server/files/MassActionsBar.tsx b/resources/scripts/components/server/files/MassActionsBar.tsx new file mode 100644 index 000000000..1d8e5c9cf --- /dev/null +++ b/resources/scripts/components/server/files/MassActionsBar.tsx @@ -0,0 +1,87 @@ +import React, { useState } from 'react'; +import tw from 'twin.macro'; +import Button from '@/components/elements/Button'; +import { useFormikContext } from 'formik'; +import Fade from '@/components/elements/Fade'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faFileArchive, faLevelUpAlt, faTrashAlt } from '@fortawesome/free-solid-svg-icons'; +import SpinnerOverlay from '@/components/elements/SpinnerOverlay'; +import useFileManagerSwr from '@/plugins/useFileManagerSwr'; +import useFlash from '@/plugins/useFlash'; +import compressFiles from '@/api/server/files/compressFiles'; +import useServer from '@/plugins/useServer'; +import { ServerContext } from '@/state/server'; +import ConfirmationModal from '@/components/elements/ConfirmationModal'; +import deleteFiles from '@/api/server/files/deleteFiles'; + +const MassActionsBar = () => { + const { uuid } = useServer(); + const { mutate } = useFileManagerSwr(); + const { clearFlashes, clearAndAddHttpError } = useFlash(); + const [ loading, setLoading ] = useState(false); + const [ showConfirm, setShowConfirm ] = useState(false); + const { values, setFieldValue } = useFormikContext<{ selectedFiles: string[] }>(); + const directory = ServerContext.useStoreState(state => state.files.directory); + + const onClickCompress = () => { + setLoading(true); + clearFlashes('files'); + + compressFiles(uuid, directory, values.selectedFiles) + .then(() => mutate()) + .then(() => setFieldValue('selectedFiles', [])) + .catch(error => clearAndAddHttpError({ key: 'files', error })) + .then(() => setLoading(false)); + }; + + const onClickConfirmDeletion = () => { + setLoading(true); + setShowConfirm(false); + clearFlashes('files'); + + deleteFiles(uuid, directory, values.selectedFiles) + .then(() => { + mutate(files => files.filter(f => values.selectedFiles.indexOf(f.name) < 0), false); + setFieldValue('selectedFiles', []); + }) + .catch(error => { + mutate(); + clearAndAddHttpError({ key: 'files', error }); + }) + .then(() => setLoading(false)); + }; + + return ( + 0} unmountOnExit> +
+ + setShowConfirm(false)} + > + Deleting files is a permanent operation, you cannot undo this action. + +
+ + + +
+
+
+ ); +}; + +export default MassActionsBar;