Skip to content

Commit 2692e98

Browse files
committed
Massive speed improvements to filemanager
1 parent fdec3ce commit 2692e98

File tree

4 files changed

+135
-140
lines changed

4 files changed

+135
-140
lines changed

resources/scripts/api/server/files/loadDirectory.ts

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,21 @@ export interface FileObject {
1414
modifiedAt: Date;
1515
}
1616

17-
export default (uuid: string, directory?: string): Promise<FileObject[]> => {
18-
return new Promise((resolve, reject) => {
19-
http.get(`/api/client/servers/${uuid}/files/list`, {
20-
params: { directory },
21-
})
22-
.then(response => resolve((response.data.data || []).map((item: any): FileObject => ({
23-
uuid: v4(),
24-
name: item.attributes.name,
25-
mode: item.attributes.mode,
26-
size: Number(item.attributes.size),
27-
isFile: item.attributes.is_file,
28-
isSymlink: item.attributes.is_symlink,
29-
isEditable: item.attributes.is_editable,
30-
mimetype: item.attributes.mimetype,
31-
createdAt: new Date(item.attributes.created_at),
32-
modifiedAt: new Date(item.attributes.modified_at),
33-
}))))
34-
.catch(reject);
17+
export default async (uuid: string, directory?: string): Promise<FileObject[]> => {
18+
const { data } = await http.get(`/api/client/servers/${uuid}/files/list`, {
19+
params: { directory },
3520
});
21+
22+
return (data.data || []).map((item: any): FileObject => ({
23+
uuid: v4(),
24+
name: item.attributes.name,
25+
mode: item.attributes.mode,
26+
size: Number(item.attributes.size),
27+
isFile: item.attributes.is_file,
28+
isSymlink: item.attributes.is_symlink,
29+
isEditable: item.attributes.is_editable,
30+
mimetype: item.attributes.mimetype,
31+
createdAt: new Date(item.attributes.created_at),
32+
modifiedAt: new Date(item.attributes.modified_at),
33+
}));
3634
};
Lines changed: 62 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -1,116 +1,94 @@
1-
import React, { useEffect, useState } from 'react';
2-
import FlashMessageRender from '@/components/FlashMessageRender';
3-
import { ServerContext } from '@/state/server';
4-
import { Actions, useStoreActions } from 'easy-peasy';
5-
import { ApplicationStore } from '@/state';
1+
import React, { useEffect } from 'react';
62
import { httpErrorToHuman } from '@/api/http';
73
import { CSSTransition } from 'react-transition-group';
84
import Spinner from '@/components/elements/Spinner';
95
import FileObjectRow from '@/components/server/files/FileObjectRow';
106
import FileManagerBreadcrumbs from '@/components/server/files/FileManagerBreadcrumbs';
11-
import { FileObject } from '@/api/server/files/loadDirectory';
7+
import loadDirectory, { FileObject } from '@/api/server/files/loadDirectory';
128
import NewDirectoryButton from '@/components/server/files/NewDirectoryButton';
13-
import { Link } from 'react-router-dom';
9+
import { Link, useLocation } from 'react-router-dom';
1410
import Can from '@/components/elements/Can';
1511
import PageContentBlock from '@/components/elements/PageContentBlock';
1612
import ServerError from '@/components/screens/ServerError';
1713
import tw from 'twin.macro';
1814
import Button from '@/components/elements/Button';
15+
import useSWR from 'swr';
16+
import useServer from '@/plugins/useServer';
17+
import { cleanDirectoryPath } from '@/helpers';
18+
import { ServerContext } from '@/state/server';
1919

2020
const sortFiles = (files: FileObject[]): FileObject[] => {
2121
return files.sort((a, b) => a.name.localeCompare(b.name))
2222
.sort((a, b) => a.isFile === b.isFile ? 0 : (a.isFile ? 1 : -1));
2323
};
2424

2525
export default () => {
26-
const [ error, setError ] = useState('');
27-
const [ loading, setLoading ] = useState(true);
28-
const { clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes);
29-
const { id } = ServerContext.useStoreState(state => state.server.data!);
30-
const { contents: files } = ServerContext.useStoreState(state => state.files);
31-
const { getDirectoryContents } = ServerContext.useStoreActions(actions => actions.files);
26+
const { hash } = useLocation();
27+
const { id, uuid } = useServer();
28+
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
3229

33-
const loadContents = () => {
34-
setError('');
35-
clearFlashes();
36-
setLoading(true);
37-
getDirectoryContents(window.location.hash)
38-
.then(() => setLoading(false))
39-
.catch(error => {
40-
console.error(error.message, { error });
41-
setError(httpErrorToHuman(error));
42-
});
43-
};
30+
const { data: files, error, mutate } = useSWR(
31+
`${uuid}:files:${hash}`,
32+
() => loadDirectory(uuid, cleanDirectoryPath(window.location.hash)),
33+
);
4434

4535
useEffect(() => {
46-
loadContents();
47-
}, []);
36+
setDirectory(hash.length > 0 ? hash : '/');
37+
}, [ hash ]);
4838

4939
if (error) {
5040
return (
51-
<ServerError
52-
message={error}
53-
onRetry={() => loadContents()}
54-
/>
41+
<ServerError message={httpErrorToHuman(error)} onRetry={() => mutate()}/>
5542
);
5643
}
5744

5845
return (
59-
<PageContentBlock>
60-
<FlashMessageRender byKey={'files'} css={tw`mb-4`}/>
61-
<React.Fragment>
62-
<FileManagerBreadcrumbs/>
63-
{
64-
loading ?
65-
<Spinner size={'large'} centered/>
66-
:
67-
<React.Fragment>
68-
{!files.length ?
69-
<p css={tw`text-sm text-neutral-400 text-center`}>
70-
This directory seems to be empty.
71-
</p>
72-
:
73-
<CSSTransition classNames={'fade'} timeout={150} appear in>
74-
<React.Fragment>
75-
<div>
76-
{files.length > 250 ?
77-
<React.Fragment>
78-
<div css={tw`rounded bg-yellow-400 mb-px p-3`}>
79-
<p css={tw`text-yellow-900 text-sm text-center`}>
80-
This directory is too large to display in the browser,
81-
limiting the output to the first 250 files.
82-
</p>
83-
</div>
84-
{
85-
sortFiles(files.slice(0, 250)).map(file => (
86-
<FileObjectRow key={file.uuid} file={file}/>
87-
))
88-
}
89-
</React.Fragment>
90-
:
91-
sortFiles(files).map(file => (
92-
<FileObjectRow key={file.uuid} file={file}/>
93-
))
94-
}
46+
<PageContentBlock showFlashKey={'files'}>
47+
<FileManagerBreadcrumbs/>
48+
{
49+
!files ?
50+
<Spinner size={'large'} centered/>
51+
:
52+
<>
53+
{!files.length ?
54+
<p css={tw`text-sm text-neutral-400 text-center`}>
55+
This directory seems to be empty.
56+
</p>
57+
:
58+
<CSSTransition classNames={'fade'} timeout={150} appear in>
59+
<React.Fragment>
60+
<div>
61+
{files.length > 250 &&
62+
<div css={tw`rounded bg-yellow-400 mb-px p-3`}>
63+
<p css={tw`text-yellow-900 text-sm text-center`}>
64+
This directory is too large to display in the browser,
65+
limiting the output to the first 250 files.
66+
</p>
9567
</div>
96-
</React.Fragment>
97-
</CSSTransition>
98-
}
99-
<Can action={'file.create'}>
100-
<div css={tw`flex justify-end mt-8`}>
101-
<NewDirectoryButton/>
102-
<Button
103-
// @ts-ignore
104-
as={Link}
105-
to={`/server/${id}/files/new${window.location.hash}`}
106-
>
107-
New File
108-
</Button>
109-
</div>
110-
</Can>
111-
</React.Fragment>
112-
}
113-
</React.Fragment>
68+
}
69+
{
70+
sortFiles(files.slice(0, 250)).map(file => (
71+
<FileObjectRow key={file.uuid} file={file}/>
72+
))
73+
}
74+
</div>
75+
</React.Fragment>
76+
</CSSTransition>
77+
}
78+
<Can action={'file.create'}>
79+
<div css={tw`flex justify-end mt-8`}>
80+
<NewDirectoryButton/>
81+
<Button
82+
// @ts-ignore
83+
as={Link}
84+
to={`/server/${id}/files/new${window.location.hash}`}
85+
>
86+
New File
87+
</Button>
88+
</div>
89+
</Can>
90+
</>
91+
}
11492
</PageContentBlock>
11593
);
11694
};

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

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,41 +2,46 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
22
import { faFileAlt, faFileImport, faFolder } from '@fortawesome/free-solid-svg-icons';
33
import { bytesToHuman, cleanDirectoryPath } from '@/helpers';
44
import { differenceInHours, format, formatDistanceToNow } from 'date-fns';
5-
import React from 'react';
5+
import React, { memo } from 'react';
66
import { FileObject } from '@/api/server/files/loadDirectory';
77
import FileDropdownMenu from '@/components/server/files/FileDropdownMenu';
88
import { ServerContext } from '@/state/server';
99
import { NavLink, useHistory, useRouteMatch } from 'react-router-dom';
1010
import tw from 'twin.macro';
11+
import isEqual from 'react-fast-compare';
12+
import styled from 'styled-components/macro';
1113

12-
export default ({ file }: { file: FileObject }) => {
14+
const Row = styled.div`
15+
${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`};
16+
`;
17+
18+
const FileObjectRow = ({ file }: { file: FileObject }) => {
1319
const directory = ServerContext.useStoreState(state => state.files.directory);
1420
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
1521

1622
const history = useHistory();
1723
const match = useRouteMatch();
1824

25+
const onRowClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
26+
// Don't rely on the onClick to work with the generated URL. Because of the way this
27+
// component re-renders you'll get redirected into a nested directory structure since
28+
// it'll cause the directory variable to update right away when you click.
29+
//
30+
// Just trust me future me, leave this be.
31+
if (!file.isFile) {
32+
e.preventDefault();
33+
34+
history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`);
35+
setDirectory(`${directory}/${file.name}`);
36+
}
37+
};
38+
1939
return (
20-
<div
21-
key={file.name}
22-
css={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`}
23-
>
40+
<Row key={file.name}>
2441
<NavLink
2542
to={`${match.url}/${file.isFile ? 'edit/' : ''}#${cleanDirectoryPath(`${directory}/${file.name}`)}`}
2643
css={tw`flex flex-1 text-neutral-300 no-underline p-3`}
27-
onClick={e => {
28-
// Don't rely on the onClick to work with the generated URL. Because of the way this
29-
// component re-renders you'll get redirected into a nested directory structure since
30-
// it'll cause the directory variable to update right away when you click.
31-
//
32-
// Just trust me future me, leave this be.
33-
if (!file.isFile) {
34-
e.preventDefault();
35-
36-
history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`);
37-
setDirectory(`${directory}/${file.name}`);
38-
}
39-
}}
44+
onClick={onRowClick}
4045
>
4146
<div css={tw`flex-none text-neutral-400 mr-4 text-lg pl-3`}>
4247
{file.isFile ?
@@ -65,6 +70,8 @@ export default ({ file }: { file: FileObject }) => {
6570
</div>
6671
</NavLink>
6772
<FileDropdownMenu uuid={file.uuid}/>
68-
</div>
73+
</Row>
6974
);
7075
};
76+
77+
export default memo(FileObjectRow, (prevProps, nextProps) => isEqual(prevProps.file, nextProps.file));

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

Lines changed: 30 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ import createDirectory from '@/api/server/files/createDirectory';
99
import v4 from 'uuid/v4';
1010
import tw from 'twin.macro';
1111
import Button from '@/components/elements/Button';
12+
import { mutate } from 'swr';
13+
import useServer from '@/plugins/useServer';
14+
import { FileObject } from '@/api/server/files/loadDirectory';
15+
import { useLocation } from 'react-router';
16+
import useFlash from '@/plugins/useFlash';
1217

1318
interface Values {
1419
directoryName: string;
@@ -18,37 +23,44 @@ const schema = object().shape({
1823
directoryName: string().required('A valid directory name must be provided.'),
1924
});
2025

26+
const generateDirectoryData = (name: string): FileObject => ({
27+
uuid: v4(),
28+
name: name,
29+
mode: '0644',
30+
size: 0,
31+
isFile: false,
32+
isEditable: false,
33+
isSymlink: false,
34+
mimetype: '',
35+
createdAt: new Date(),
36+
modifiedAt: new Date(),
37+
});
38+
2139
export default () => {
40+
const { uuid } = useServer();
41+
const { hash } = useLocation();
42+
const { clearAndAddHttpError } = useFlash();
2243
const [ visible, setVisible ] = useState(false);
23-
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid);
2444
const directory = ServerContext.useStoreState(state => state.files.directory);
25-
const pushFile = ServerContext.useStoreActions(actions => actions.files.pushFile);
2645

27-
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => {
28-
createDirectory(uuid, directory, values.directoryName)
46+
const submit = ({ directoryName }: Values, { setSubmitting }: FormikHelpers<Values>) => {
47+
createDirectory(uuid, directory, directoryName)
2948
.then(() => {
30-
pushFile({
31-
uuid: v4(),
32-
name: values.directoryName,
33-
mode: '0644',
34-
size: 0,
35-
isFile: false,
36-
isEditable: false,
37-
isSymlink: false,
38-
mimetype: '',
39-
createdAt: new Date(),
40-
modifiedAt: new Date(),
41-
});
49+
mutate(
50+
`${uuid}:files:${hash}`,
51+
(data: FileObject[]) => [ ...data, generateDirectoryData(directoryName) ],
52+
);
4253
setVisible(false);
4354
})
4455
.catch(error => {
4556
console.error(error);
4657
setSubmitting(false);
58+
clearAndAddHttpError({ key: 'files', error });
4759
});
4860
};
4961

5062
return (
51-
<React.Fragment>
63+
<>
5264
<Formik
5365
onSubmit={submit}
5466
validationSchema={schema}
@@ -91,6 +103,6 @@ export default () => {
91103
<Button isSecondary css={tw`mr-2`} onClick={() => setVisible(true)}>
92104
Create Directory
93105
</Button>
94-
</React.Fragment>
106+
</>
95107
);
96108
};

0 commit comments

Comments
 (0)