Massive speed improvements to filemanager

This commit is contained in:
Dane Everitt 2020-07-10 22:10:51 -07:00
parent fdec3cea80
commit 2692e98cd8
No known key found for this signature in database
GPG Key ID: EEA66103B3D71F53
4 changed files with 138 additions and 143 deletions

View File

@ -14,23 +14,21 @@ export interface FileObject {
modifiedAt: Date; modifiedAt: Date;
} }
export default (uuid: string, directory?: string): Promise<FileObject[]> => { export default async (uuid: string, directory?: string): Promise<FileObject[]> => {
return new Promise((resolve, reject) => { const { data } = await http.get(`/api/client/servers/${uuid}/files/list`, {
http.get(`/api/client/servers/${uuid}/files/list`, { params: { directory },
params: { directory },
})
.then(response => resolve((response.data.data || []).map((item: any): FileObject => ({
uuid: v4(),
name: item.attributes.name,
mode: item.attributes.mode,
size: Number(item.attributes.size),
isFile: item.attributes.is_file,
isSymlink: item.attributes.is_symlink,
isEditable: item.attributes.is_editable,
mimetype: item.attributes.mimetype,
createdAt: new Date(item.attributes.created_at),
modifiedAt: new Date(item.attributes.modified_at),
}))))
.catch(reject);
}); });
return (data.data || []).map((item: any): FileObject => ({
uuid: v4(),
name: item.attributes.name,
mode: item.attributes.mode,
size: Number(item.attributes.size),
isFile: item.attributes.is_file,
isSymlink: item.attributes.is_symlink,
isEditable: item.attributes.is_editable,
mimetype: item.attributes.mimetype,
createdAt: new Date(item.attributes.created_at),
modifiedAt: new Date(item.attributes.modified_at),
}));
}; };

View File

@ -1,21 +1,21 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect } from 'react';
import FlashMessageRender from '@/components/FlashMessageRender';
import { ServerContext } from '@/state/server';
import { Actions, useStoreActions } from 'easy-peasy';
import { ApplicationStore } from '@/state';
import { httpErrorToHuman } from '@/api/http'; import { httpErrorToHuman } from '@/api/http';
import { CSSTransition } from 'react-transition-group'; import { CSSTransition } from 'react-transition-group';
import Spinner from '@/components/elements/Spinner'; import Spinner from '@/components/elements/Spinner';
import FileObjectRow from '@/components/server/files/FileObjectRow'; import FileObjectRow from '@/components/server/files/FileObjectRow';
import FileManagerBreadcrumbs from '@/components/server/files/FileManagerBreadcrumbs'; import FileManagerBreadcrumbs from '@/components/server/files/FileManagerBreadcrumbs';
import { FileObject } from '@/api/server/files/loadDirectory'; import loadDirectory, { FileObject } from '@/api/server/files/loadDirectory';
import NewDirectoryButton from '@/components/server/files/NewDirectoryButton'; import NewDirectoryButton from '@/components/server/files/NewDirectoryButton';
import { Link } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import Can from '@/components/elements/Can'; import Can from '@/components/elements/Can';
import PageContentBlock from '@/components/elements/PageContentBlock'; import PageContentBlock from '@/components/elements/PageContentBlock';
import ServerError from '@/components/screens/ServerError'; import ServerError from '@/components/screens/ServerError';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import useSWR from 'swr';
import useServer from '@/plugins/useServer';
import { cleanDirectoryPath } from '@/helpers';
import { ServerContext } from '@/state/server';
const sortFiles = (files: FileObject[]): FileObject[] => { const sortFiles = (files: FileObject[]): FileObject[] => {
return files.sort((a, b) => a.name.localeCompare(b.name)) return files.sort((a, b) => a.name.localeCompare(b.name))
@ -23,94 +23,72 @@ const sortFiles = (files: FileObject[]): FileObject[] => {
}; };
export default () => { export default () => {
const [ error, setError ] = useState(''); const { hash } = useLocation();
const [ loading, setLoading ] = useState(true); const { id, uuid } = useServer();
const { clearFlashes } = useStoreActions((actions: Actions<ApplicationStore>) => actions.flashes); const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
const { id } = ServerContext.useStoreState(state => state.server.data!);
const { contents: files } = ServerContext.useStoreState(state => state.files);
const { getDirectoryContents } = ServerContext.useStoreActions(actions => actions.files);
const loadContents = () => { const { data: files, error, mutate } = useSWR(
setError(''); `${uuid}:files:${hash}`,
clearFlashes(); () => loadDirectory(uuid, cleanDirectoryPath(window.location.hash)),
setLoading(true); );
getDirectoryContents(window.location.hash)
.then(() => setLoading(false))
.catch(error => {
console.error(error.message, { error });
setError(httpErrorToHuman(error));
});
};
useEffect(() => { useEffect(() => {
loadContents(); setDirectory(hash.length > 0 ? hash : '/');
}, []); }, [ hash ]);
if (error) { if (error) {
return ( return (
<ServerError <ServerError message={httpErrorToHuman(error)} onRetry={() => mutate()}/>
message={error}
onRetry={() => loadContents()}
/>
); );
} }
return ( return (
<PageContentBlock> <PageContentBlock showFlashKey={'files'}>
<FlashMessageRender byKey={'files'} css={tw`mb-4`}/> <FileManagerBreadcrumbs/>
<React.Fragment> {
<FileManagerBreadcrumbs/> !files ?
{ <Spinner size={'large'} centered/>
loading ? :
<Spinner size={'large'} centered/> <>
: {!files.length ?
<React.Fragment> <p css={tw`text-sm text-neutral-400 text-center`}>
{!files.length ? This directory seems to be empty.
<p css={tw`text-sm text-neutral-400 text-center`}> </p>
This directory seems to be empty. :
</p> <CSSTransition classNames={'fade'} timeout={150} appear in>
: <React.Fragment>
<CSSTransition classNames={'fade'} timeout={150} appear in> <div>
<React.Fragment> {files.length > 250 &&
<div> <div css={tw`rounded bg-yellow-400 mb-px p-3`}>
{files.length > 250 ? <p css={tw`text-yellow-900 text-sm text-center`}>
<React.Fragment> This directory is too large to display in the browser,
<div css={tw`rounded bg-yellow-400 mb-px p-3`}> limiting the output to the first 250 files.
<p css={tw`text-yellow-900 text-sm text-center`}> </p>
This directory is too large to display in the browser,
limiting the output to the first 250 files.
</p>
</div>
{
sortFiles(files.slice(0, 250)).map(file => (
<FileObjectRow key={file.uuid} file={file}/>
))
}
</React.Fragment>
:
sortFiles(files).map(file => (
<FileObjectRow key={file.uuid} file={file}/>
))
}
</div> </div>
</React.Fragment> }
</CSSTransition> {
} sortFiles(files.slice(0, 250)).map(file => (
<Can action={'file.create'}> <FileObjectRow key={file.uuid} file={file}/>
<div css={tw`flex justify-end mt-8`}> ))
<NewDirectoryButton/> }
<Button </div>
// @ts-ignore </React.Fragment>
as={Link} </CSSTransition>
to={`/server/${id}/files/new${window.location.hash}`} }
> <Can action={'file.create'}>
New File <div css={tw`flex justify-end mt-8`}>
</Button> <NewDirectoryButton/>
</div> <Button
</Can> // @ts-ignore
</React.Fragment> as={Link}
} to={`/server/${id}/files/new${window.location.hash}`}
</React.Fragment> >
New File
</Button>
</div>
</Can>
</>
}
</PageContentBlock> </PageContentBlock>
); );
}; };

View File

@ -2,41 +2,46 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faFileAlt, faFileImport, faFolder } from '@fortawesome/free-solid-svg-icons'; import { faFileAlt, faFileImport, faFolder } from '@fortawesome/free-solid-svg-icons';
import { bytesToHuman, cleanDirectoryPath } from '@/helpers'; import { bytesToHuman, cleanDirectoryPath } from '@/helpers';
import { differenceInHours, format, formatDistanceToNow } from 'date-fns'; import { differenceInHours, format, formatDistanceToNow } from 'date-fns';
import React from 'react'; import React, { memo } from 'react';
import { FileObject } from '@/api/server/files/loadDirectory'; import { FileObject } from '@/api/server/files/loadDirectory';
import FileDropdownMenu from '@/components/server/files/FileDropdownMenu'; import FileDropdownMenu from '@/components/server/files/FileDropdownMenu';
import { ServerContext } from '@/state/server'; import { ServerContext } from '@/state/server';
import { NavLink, useHistory, useRouteMatch } from 'react-router-dom'; import { NavLink, useHistory, useRouteMatch } from 'react-router-dom';
import tw from 'twin.macro'; import tw from 'twin.macro';
import isEqual from 'react-fast-compare';
import styled from 'styled-components/macro';
export default ({ file }: { file: FileObject }) => { 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 FileObjectRow = ({ file }: { file: FileObject }) => {
const directory = ServerContext.useStoreState(state => state.files.directory); const directory = ServerContext.useStoreState(state => state.files.directory);
const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory); const setDirectory = ServerContext.useStoreActions(actions => actions.files.setDirectory);
const history = useHistory(); const history = useHistory();
const match = useRouteMatch(); const match = useRouteMatch();
const onRowClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) => {
// Don't rely on the onClick to work with the generated URL. Because of the way this
// component re-renders you'll get redirected into a nested directory structure since
// it'll cause the directory variable to update right away when you click.
//
// Just trust me future me, leave this be.
if (!file.isFile) {
e.preventDefault();
history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`);
setDirectory(`${directory}/${file.name}`);
}
};
return ( return (
<div <Row key={file.name}>
key={file.name}
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`}
>
<NavLink <NavLink
to={`${match.url}/${file.isFile ? 'edit/' : ''}#${cleanDirectoryPath(`${directory}/${file.name}`)}`} to={`${match.url}/${file.isFile ? 'edit/' : ''}#${cleanDirectoryPath(`${directory}/${file.name}`)}`}
css={tw`flex flex-1 text-neutral-300 no-underline p-3`} css={tw`flex flex-1 text-neutral-300 no-underline p-3`}
onClick={e => { onClick={onRowClick}
// Don't rely on the onClick to work with the generated URL. Because of the way this
// component re-renders you'll get redirected into a nested directory structure since
// it'll cause the directory variable to update right away when you click.
//
// Just trust me future me, leave this be.
if (!file.isFile) {
e.preventDefault();
history.push(`#${cleanDirectoryPath(`${directory}/${file.name}`)}`);
setDirectory(`${directory}/${file.name}`);
}
}}
> >
<div css={tw`flex-none text-neutral-400 mr-4 text-lg pl-3`}> <div css={tw`flex-none text-neutral-400 mr-4 text-lg pl-3`}>
{file.isFile ? {file.isFile ?
@ -65,6 +70,8 @@ export default ({ file }: { file: FileObject }) => {
</div> </div>
</NavLink> </NavLink>
<FileDropdownMenu uuid={file.uuid}/> <FileDropdownMenu uuid={file.uuid}/>
</div> </Row>
); );
}; };
export default memo(FileObjectRow, (prevProps, nextProps) => isEqual(prevProps.file, nextProps.file));

View File

@ -9,6 +9,11 @@ import createDirectory from '@/api/server/files/createDirectory';
import v4 from 'uuid/v4'; import v4 from 'uuid/v4';
import tw from 'twin.macro'; import tw from 'twin.macro';
import Button from '@/components/elements/Button'; import Button from '@/components/elements/Button';
import { mutate } from 'swr';
import useServer from '@/plugins/useServer';
import { FileObject } from '@/api/server/files/loadDirectory';
import { useLocation } from 'react-router';
import useFlash from '@/plugins/useFlash';
interface Values { interface Values {
directoryName: string; directoryName: string;
@ -18,37 +23,44 @@ const schema = object().shape({
directoryName: string().required('A valid directory name must be provided.'), directoryName: string().required('A valid directory name must be provided.'),
}); });
export default () => { const generateDirectoryData = (name: string): FileObject => ({
const [ visible, setVisible ] = useState(false); uuid: v4(),
const uuid = ServerContext.useStoreState(state => state.server.data!.uuid); name: name,
const directory = ServerContext.useStoreState(state => state.files.directory); mode: '0644',
const pushFile = ServerContext.useStoreActions(actions => actions.files.pushFile); size: 0,
isFile: false,
isEditable: false,
isSymlink: false,
mimetype: '',
createdAt: new Date(),
modifiedAt: new Date(),
});
const submit = (values: Values, { setSubmitting }: FormikHelpers<Values>) => { export default () => {
createDirectory(uuid, directory, values.directoryName) const { uuid } = useServer();
const { hash } = useLocation();
const { clearAndAddHttpError } = useFlash();
const [ visible, setVisible ] = useState(false);
const directory = ServerContext.useStoreState(state => state.files.directory);
const submit = ({ directoryName }: Values, { setSubmitting }: FormikHelpers<Values>) => {
createDirectory(uuid, directory, directoryName)
.then(() => { .then(() => {
pushFile({ mutate(
uuid: v4(), `${uuid}:files:${hash}`,
name: values.directoryName, (data: FileObject[]) => [ ...data, generateDirectoryData(directoryName) ],
mode: '0644', );
size: 0,
isFile: false,
isEditable: false,
isSymlink: false,
mimetype: '',
createdAt: new Date(),
modifiedAt: new Date(),
});
setVisible(false); setVisible(false);
}) })
.catch(error => { .catch(error => {
console.error(error); console.error(error);
setSubmitting(false); setSubmitting(false);
clearAndAddHttpError({ key: 'files', error });
}); });
}; };
return ( return (
<React.Fragment> <>
<Formik <Formik
onSubmit={submit} onSubmit={submit}
validationSchema={schema} validationSchema={schema}
@ -91,6 +103,6 @@ export default () => {
<Button isSecondary css={tw`mr-2`} onClick={() => setVisible(true)}> <Button isSecondary css={tw`mr-2`} onClick={() => setVisible(true)}>
Create Directory Create Directory
</Button> </Button>
</React.Fragment> </>
); );
}; };