Skip to content

Commit df9a7f7

Browse files
authored
Support canceling file uploads (pterodactyl#4441)
Closes pterodactyl#4440
1 parent a4f6870 commit df9a7f7

File tree

6 files changed

+92
-50
lines changed

6 files changed

+92
-50
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"@headlessui/react": "^1.6.4",
1212
"@heroicons/react": "^1.0.6",
1313
"@hot-loader/react-dom": "^16.14.0",
14+
"@preact/signals-react": "^1.2.1",
1415
"@tailwindcss/forms": "^0.5.2",
1516
"@tailwindcss/line-clamp": "^0.4.0",
1617
"axios": "^0.27.2",

resources/scripts/components/elements/dialog/Dialog.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ export default ({
9090
>
9191
<div className={'flex p-6 pb-0 overflow-y-auto'}>
9292
{iconPosition === 'container' && icon}
93-
<div className={'flex-1 max-h-[70vh]'}>
93+
<div className={'flex-1 max-h-[70vh] min-w-0'}>
9494
<div className={'flex items-center'}>
9595
{iconPosition !== 'container' && icon}
9696
<div>

resources/scripts/components/server/files/FileManagerStatus.tsx

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import React, { useContext, useEffect, useState } from 'react';
1+
import React, { useContext, useEffect } from 'react';
22
import { ServerContext } from '@/state/server';
3-
import { CloudUploadIcon } from '@heroicons/react/solid';
3+
import { CloudUploadIcon, XIcon } from '@heroicons/react/solid';
44
import asDialog from '@/hoc/asDialog';
55
import { Dialog, DialogWrapperContext } from '@/components/elements/dialog';
66
import { Button } from '@/components/elements/button/index';
77
import Tooltip from '@/components/elements/tooltip/Tooltip';
88
import Code from '@/components/elements/Code';
9+
import { useSignal } from '@preact/signals-react';
910

1011
const svgProps = {
1112
cx: 16,
@@ -31,23 +32,34 @@ const Spinner = ({ progress, className }: { progress: number; className?: string
3132

3233
const FileUploadList = () => {
3334
const { close } = useContext(DialogWrapperContext);
35+
const removeFileUpload = ServerContext.useStoreActions((actions) => actions.files.removeFileUpload);
36+
const clearFileUploads = ServerContext.useStoreActions((actions) => actions.files.clearFileUploads);
3437
const uploads = ServerContext.useStoreState((state) =>
35-
state.files.uploads.sort((a, b) => a.name.localeCompare(b.name))
38+
Object.entries(state.files.uploads).sort(([a], [b]) => a.localeCompare(b))
3639
);
3740

3841
return (
3942
<div className={'space-y-2 mt-6'}>
40-
{uploads.map((file) => (
41-
<div key={file.name} className={'flex items-center space-x-3 bg-gray-700 p-3 rounded'}>
43+
{uploads.map(([name, file]) => (
44+
<div key={name} className={'flex items-center space-x-3 bg-gray-700 p-3 rounded'}>
4245
<Tooltip content={`${Math.floor((file.loaded / file.total) * 100)}%`} placement={'left'}>
4346
<div className={'flex-shrink-0'}>
4447
<Spinner progress={(file.loaded / file.total) * 100} className={'w-6 h-6'} />
4548
</div>
4649
</Tooltip>
47-
<Code>{file.name}</Code>
50+
<Code className={'flex-1 truncate'}>{name}</Code>
51+
<button
52+
onClick={removeFileUpload.bind(this, name)}
53+
className={'text-gray-500 hover:text-gray-200 transition-colors duration-75'}
54+
>
55+
<XIcon className={'w-5 h-5'} />
56+
</button>
4857
</div>
4958
))}
5059
<Dialog.Footer>
60+
<Button.Danger variant={Button.Variants.Secondary} onClick={() => clearFileUploads()}>
61+
Cancel Uploads
62+
</Button.Danger>
5163
<Button.Text onClick={close}>Close</Button.Text>
5264
</Dialog.Footer>
5365
</div>
@@ -60,31 +72,34 @@ const FileUploadListDialog = asDialog({
6072
})(FileUploadList);
6173

6274
export default () => {
63-
const [open, setOpen] = useState(false);
75+
const open = useSignal(false);
6476

65-
const count = ServerContext.useStoreState((state) => state.files.uploads.length);
77+
const count = ServerContext.useStoreState((state) => Object.keys(state.files.uploads).length);
6678
const progress = ServerContext.useStoreState((state) => ({
67-
uploaded: state.files.uploads.reduce((count, file) => count + file.loaded, 0),
68-
total: state.files.uploads.reduce((count, file) => count + file.total, 0),
79+
uploaded: Object.values(state.files.uploads).reduce((count, file) => count + file.loaded, 0),
80+
total: Object.values(state.files.uploads).reduce((count, file) => count + file.total, 0),
6981
}));
7082

7183
useEffect(() => {
7284
if (count === 0) {
73-
setOpen(false);
85+
open.value = false;
7486
}
7587
}, [count]);
7688

7789
return (
7890
<>
7991
{count > 0 && (
8092
<Tooltip content={`${count} files are uploading, click to view`}>
81-
<button className={'flex items-center justify-center w-10 h-10'} onClick={setOpen.bind(this, true)}>
93+
<button
94+
className={'flex items-center justify-center w-10 h-10'}
95+
onClick={() => (open.value = true)}
96+
>
8297
<Spinner progress={(progress.uploaded / progress.total) * 100} className={'w-8 h-8'} />
8398
<CloudUploadIcon className={'h-3 absolute mx-auto animate-pulse'} />
8499
</button>
85100
</Tooltip>
86101
)}
87-
<FileUploadListDialog open={open} onClose={setOpen.bind(this, false)} />
102+
<FileUploadListDialog open={open.value} onClose={() => (open.value = false)} />
88103
</>
89104
);
90105
};

resources/scripts/components/server/files/UploadButton.tsx

Lines changed: 21 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import axios from 'axios';
22
import getFileUploadUrl from '@/api/server/files/getFileUploadUrl';
33
import tw from 'twin.macro';
44
import { Button } from '@/components/elements/button/index';
5-
import React, { useEffect, useRef, useState } from 'react';
5+
import React, { useEffect, useRef } from 'react';
66
import { ModalMask } from '@/components/elements/Modal';
77
import Fade from '@/components/elements/Fade';
88
import useEventListener from '@/plugins/useEventListener';
@@ -12,6 +12,7 @@ import { ServerContext } from '@/state/server';
1212
import { WithClassname } from '@/components/types';
1313
import Portal from '@/components/elements/Portal';
1414
import { CloudUploadIcon } from '@heroicons/react/outline';
15+
import { useSignal } from '@preact/signals-react';
1516

1617
function isFileOrDirectory(event: DragEvent): boolean {
1718
if (!event.dataTransfer?.types) {
@@ -23,14 +24,16 @@ function isFileOrDirectory(event: DragEvent): boolean {
2324

2425
export default ({ className }: WithClassname) => {
2526
const fileUploadInput = useRef<HTMLInputElement>(null);
26-
const [timeouts, setTimeouts] = useState<NodeJS.Timeout[]>([]);
27-
const [visible, setVisible] = useState(false);
27+
28+
const visible = useSignal(false);
29+
const timeouts = useSignal<NodeJS.Timeout[]>([]);
30+
2831
const { mutate } = useFileManagerSwr();
2932
const { addError, clearAndAddHttpError } = useFlashKey('files');
3033

3134
const uuid = ServerContext.useStoreState((state) => state.server.data!.uuid);
3235
const directory = ServerContext.useStoreState((state) => state.files.directory);
33-
const { clearFileUploads, appendFileUpload, removeFileUpload } = ServerContext.useStoreActions(
36+
const { clearFileUploads, removeFileUpload, pushFileUpload, setUploadProgress } = ServerContext.useStoreActions(
3437
(actions) => actions.files
3538
);
3639

@@ -40,27 +43,24 @@ export default ({ className }: WithClassname) => {
4043
e.preventDefault();
4144
e.stopPropagation();
4245
if (isFileOrDirectory(e)) {
43-
return setVisible(true);
46+
visible.value = true;
4447
}
4548
},
4649
{ capture: true }
4750
);
4851

49-
useEventListener('dragexit', () => setVisible(false), { capture: true });
52+
useEventListener('dragexit', () => (visible.value = false), { capture: true });
5053

51-
useEventListener('keydown', () => {
52-
visible && setVisible(false);
53-
});
54+
useEventListener('keydown', () => (visible.value = false));
5455

5556
useEffect(() => {
56-
return () => timeouts.forEach(clearTimeout);
57+
return () => timeouts.value.forEach(clearTimeout);
5758
}, []);
5859

5960
const onUploadProgress = (data: ProgressEvent, name: string) => {
60-
appendFileUpload({ name, loaded: data.loaded, total: data.total });
61+
setUploadProgress({ name, loaded: data.loaded });
6162
if (data.loaded >= data.total) {
62-
const timeout = setTimeout(() => removeFileUpload(name), 500);
63-
setTimeouts((t) => [...t, timeout]);
63+
timeouts.value.push(setTimeout(() => removeFileUpload(name), 500));
6464
}
6565
};
6666

@@ -71,23 +71,20 @@ export default ({ className }: WithClassname) => {
7171
return addError('Folder uploads are not supported at this time.', 'Error');
7272
}
7373

74-
if (!list.length) {
75-
return;
76-
}
77-
7874
const uploads = list.map((file) => {
79-
appendFileUpload({ name: file.name, loaded: 0, total: file.size });
75+
const controller = new AbortController();
76+
pushFileUpload({ name: file.name, data: { abort: controller, loaded: 0, total: file.size } });
77+
8078
return () =>
8179
getFileUploadUrl(uuid).then((url) =>
8280
axios.post(
8381
url,
8482
{ files: file },
8583
{
84+
signal: controller.signal,
8685
headers: { 'Content-Type': 'multipart/form-data' },
8786
params: { directory },
88-
onUploadProgress: (data) => {
89-
onUploadProgress(data, file.name);
90-
},
87+
onUploadProgress: (data) => onUploadProgress(data, file.name),
9188
}
9289
)
9390
);
@@ -104,15 +101,15 @@ export default ({ className }: WithClassname) => {
104101
return (
105102
<>
106103
<Portal>
107-
<Fade appear in={visible} timeout={75} key={'upload_modal_mask'} unmountOnExit>
104+
<Fade appear in={visible.value} timeout={75} key={'upload_modal_mask'} unmountOnExit>
108105
<ModalMask
109-
onClick={() => setVisible(false)}
106+
onClick={() => (visible.value = false)}
110107
onDragOver={(e) => e.preventDefault()}
111108
onDrop={(e) => {
112109
e.preventDefault();
113110
e.stopPropagation();
114111

115-
setVisible(false);
112+
visible.value = false;
116113
if (!e.dataTransfer?.files.length) return;
117114

118115
onFileSubmission(e.dataTransfer.files);

resources/scripts/state/server/files.ts

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,32 @@
11
import { action, Action } from 'easy-peasy';
22
import { cleanDirectoryPath } from '@/helpers';
33

4-
export interface FileUpload {
5-
name: string;
4+
export interface FileUploadData {
65
loaded: number;
6+
readonly abort: AbortController;
77
readonly total: number;
88
}
99

1010
export interface ServerFileStore {
1111
directory: string;
1212
selectedFiles: string[];
13-
uploads: FileUpload[];
13+
uploads: Record<string, FileUploadData>;
1414

1515
setDirectory: Action<ServerFileStore, string>;
1616
setSelectedFiles: Action<ServerFileStore, string[]>;
1717
appendSelectedFile: Action<ServerFileStore, string>;
1818
removeSelectedFile: Action<ServerFileStore, string>;
1919

20+
pushFileUpload: Action<ServerFileStore, { name: string; data: FileUploadData }>;
21+
setUploadProgress: Action<ServerFileStore, { name: string; loaded: number }>;
2022
clearFileUploads: Action<ServerFileStore>;
21-
appendFileUpload: Action<ServerFileStore, FileUpload>;
2223
removeFileUpload: Action<ServerFileStore, string>;
2324
}
2425

2526
const files: ServerFileStore = {
2627
directory: '/',
2728
selectedFiles: [],
28-
uploads: [],
29+
uploads: {},
2930

3031
setDirectory: action((state, payload) => {
3132
state.directory = cleanDirectoryPath(payload);
@@ -44,19 +45,29 @@ const files: ServerFileStore = {
4445
}),
4546

4647
clearFileUploads: action((state) => {
47-
state.uploads = [];
48+
Object.values(state.uploads).forEach((upload) => upload.abort.abort());
49+
50+
state.uploads = {};
51+
}),
52+
53+
pushFileUpload: action((state, payload) => {
54+
state.uploads[payload.name] = payload.data;
4855
}),
4956

50-
appendFileUpload: action((state, payload) => {
51-
if (!state.uploads.some(({ name }) => name === payload.name)) {
52-
state.uploads = [...state.uploads, payload];
53-
} else {
54-
state.uploads = state.uploads.map((file) => (file.name === payload.name ? payload : file));
57+
setUploadProgress: action((state, { name, loaded }) => {
58+
if (state.uploads[name]) {
59+
state.uploads[name].loaded = loaded;
5560
}
5661
}),
5762

5863
removeFileUpload: action((state, payload) => {
59-
state.uploads = state.uploads.filter(({ name }) => name !== payload);
64+
if (state.uploads[payload]) {
65+
// Abort the request if it is still in flight. If it already completed this is
66+
// a no-op.
67+
state.uploads[payload].abort.abort();
68+
69+
delete state.uploads[payload];
70+
}
6071
}),
6172
};
6273

yarn.lock

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1493,6 +1493,19 @@
14931493
mkdirp "^1.0.4"
14941494
rimraf "^3.0.2"
14951495

1496+
"@preact/signals-core@^1.2.2":
1497+
version "1.2.2"
1498+
resolved "https://registry.yarnpkg.com/@preact/signals-core/-/signals-core-1.2.2.tgz#279dcc5ab249de2f2e8f6e6779b1958256ba843e"
1499+
integrity sha512-z3/bCj7rRA21RJb4FeJ4guCrD1CQbaURHkCTunUWQpxUMAFOPXCD8tSFqERyGrrcSb4T3Hrmdc1OAl0LXBHwiw==
1500+
1501+
"@preact/signals-react@^1.2.1":
1502+
version "1.2.1"
1503+
resolved "https://registry.yarnpkg.com/@preact/signals-react/-/signals-react-1.2.1.tgz#6d5d305ebdb38c879043acebc65c0d9351e663c1"
1504+
integrity sha512-73J8sL1Eru7Ot4yBYOCPj1izEZjzCEXlembRgk6C7PkwsqoAVbCxMlDOFfCLoPFuJ6qeGatrJzRkcycXppMqVQ==
1505+
dependencies:
1506+
"@preact/signals-core" "^1.2.2"
1507+
use-sync-external-store "^1.2.0"
1508+
14961509
"@sinclair/typebox@^0.23.3":
14971510
version "0.23.5"
14981511
resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.23.5.tgz#93f7b9f4e3285a7a9ade7557d9a8d36809cbc47d"
@@ -9121,6 +9134,11 @@ use-memo-one@^1.1.1:
91219134
resolved "https://registry.yarnpkg.com/use-memo-one/-/use-memo-one-1.1.1.tgz#39e6f08fe27e422a7d7b234b5f9056af313bd22c"
91229135
integrity sha512-oFfsyun+bP7RX8X2AskHNTxu+R3QdE/RC5IefMbqptmACAA/gfol1KDD5KRzPsGMa62sWxGZw+Ui43u6x4ddoQ==
91239136

9137+
use-sync-external-store@^1.2.0:
9138+
version "1.2.0"
9139+
resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"
9140+
integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==
9141+
91249142
use@^3.1.0:
91259143
version "3.1.0"
91269144
resolved "https://registry.yarnpkg.com/use/-/use-3.1.0.tgz#14716bf03fdfefd03040aef58d8b4b85f3a7c544"

0 commit comments

Comments
 (0)