FilesystemBrowser: redesign to allow searching in current directory

Plus, this greatly reduces the number of unnecessary requests sent
to Flood server.
This commit is contained in:
Jesse Chan
2021-03-07 01:10:59 +08:00
parent 1ce8ba49d5
commit afffb6d18c
7 changed files with 226 additions and 184 deletions

View File

@@ -1,8 +1,42 @@
import {css} from '@emotion/react';
import {darken, lighten, rgba, saturate} from 'polished';
import {FC, memo, ReactNodeArray, useEffect, useState} from 'react';
import sort from 'fast-sort';
import {Trans} from '@lingui/react';
import {Arrow, File, FolderClosedSolid} from '@client/ui/icons';
import {Arrow, File, FolderClosedOutlined, FolderClosedSolid, FolderOpenSolid} from '@client/ui/icons';
import FloodActions from '@client/actions/FloodActions';
import termMatch from '@client/util/termMatch';
const foregroundColor = '#5E728C';
const headerStyle = css({
borderBottom: `1px solid ${lighten(0.43, foregroundColor)}`,
marginBottom: '3px',
paddingBottom: '3px',
opacity: 0.75,
'&:last-child': {
marginBottom: 0,
},
});
const listItemStyle = css({
opacity: 0.5,
padding: '3px 9px',
transition: 'color 0.25s',
whiteSpace: 'nowrap',
});
const listItemSelectableStyle = css({
opacity: 1,
cursor: 'pointer',
transition: 'background 0.25s, color 0.25s',
userSelect: 'none',
'&:hover': {
color: saturate(0.1, darken(0.15, foregroundColor)),
background: rgba(foregroundColor, 0.1),
},
});
const MESSAGES = {
EACCES: 'filesystem.error.eacces',
@@ -12,157 +46,191 @@ const MESSAGES = {
interface FilesystemBrowserProps {
selectable?: 'files' | 'directories';
directory: string;
onItemSelection?: (newDestination: string, isDirectory?: boolean) => void;
onItemSelection?: (newDestination: string, shouldKeepOpen?: boolean) => void;
}
const FilesystemBrowser: FC<FilesystemBrowserProps> = memo(
({directory, selectable, onItemSelection}: FilesystemBrowserProps) => {
const [errorResponse, setErrorResponse] = useState<{data?: NodeJS.ErrnoException} | null>(null);
const [separator, setSeparator] = useState<string>('/');
const [directories, setDirectories] = useState<string[]>([]);
const [files, setFiles] = useState<string[]>([]);
const [separator, setSeparator] = useState<string>(directory.includes('/') ? '/' : '\\');
const [directories, setDirectories] = useState<string[] | null>(null);
const [files, setFiles] = useState<string[] | null>(null);
const lastSegmentIndex = directory.lastIndexOf(separator) + 1;
const currentDirectory = lastSegmentIndex > 0 ? directory.substr(0, lastSegmentIndex) : directory;
const lastSegment = directory.substr(lastSegmentIndex);
useEffect(() => {
if (!directory) {
if (!currentDirectory) {
return;
}
FloodActions.fetchDirectoryList(directory)
setDirectories(null);
setFiles(null);
FloodActions.fetchDirectoryList(currentDirectory)
.then(({files: fetchedFiles, directories: fetchedDirectories, separator: fetchedSeparator}) => {
setFiles(fetchedFiles);
setDirectories(fetchedDirectories);
setFiles(fetchedFiles);
setSeparator(fetchedSeparator);
setErrorResponse(null);
})
.catch(({response}) => {
setErrorResponse(response);
});
}, [directory]);
}, [currentDirectory]);
let errorMessage = null;
let errorMessage: string | null = null;
let listItems = null;
let parentDirectoryElement = null;
let shouldShowDirectoryList = true;
if ((directories == null && selectable === 'directories') || (files == null && selectable === 'files')) {
shouldShowDirectoryList = false;
errorMessage = (
<div className="filesystem__directory-list__item filesystem__directory-list__item--message">
<em>
<Trans id="filesystem.fetching" />
</em>
</div>
);
errorMessage = 'filesystem.fetching';
}
if (errorResponse && errorResponse.data && errorResponse.data.code) {
shouldShowDirectoryList = false;
errorMessage = (
<div className="filesystem__directory-list__item filesystem__directory-list__item--message">
<em>
<Trans id={MESSAGES[errorResponse.data.code as keyof typeof MESSAGES] || 'filesystem.error.unknown'} />
</em>
</div>
);
errorMessage = MESSAGES[errorResponse.data.code as keyof typeof MESSAGES] || 'filesystem.error.unknown';
}
if (directory) {
parentDirectoryElement = (
if (!directory) {
errorMessage = 'filesystem.error.no.input';
} else {
const parentDirectory = `${currentDirectory.split(separator).slice(0, -2).join(separator)}${separator}`;
const parentDirectoryElement = (
<li
className="filesystem__directory-list__item filesystem__directory-list__item--parent"
onClick={() => {
let parentDirectory = directory;
if (directory.endsWith(separator)) {
parentDirectory = directory.substring(0, directory.length - 1);
}
const directoryArr = directory.split(separator);
directoryArr.pop();
parentDirectory = directoryArr.join(separator);
onItemSelection?.(parentDirectory);
}}>
<Arrow />
<Trans id="filesystem.parent.directory" />
css={[
listItemStyle,
listItemSelectableStyle,
{
'@media (max-width: 720px)': headerStyle,
},
]}
key={parentDirectory}
onClick={selectable !== 'files' ? () => onItemSelection?.(parentDirectory, true) : undefined}>
<Arrow css={{transform: 'scale(0.75) rotate(180deg)'}} />
..
</li>
);
} else {
shouldShowDirectoryList = false;
errorMessage = (
<div className="filesystem__directory-list__item filesystem__directory-list__item--message">
<em>
<Trans id="filesystem.error.no.input" />
</em>
</div>
);
}
if (shouldShowDirectoryList) {
const directoryMatched = lastSegment ? termMatch(directories, (subDirectory) => subDirectory, lastSegment) : [];
const directoryList: ReactNodeArray =
directories?.map((subDirectory) => (
<li
className={`${'filesystem__directory-list__item filesystem__directory-list__item--directory'.concat(
selectable !== 'files' ? ' filesystem__directory-list__item--selectable' : '',
)}`}
key={subDirectory}
onClick={
selectable !== 'files'
? () => {
onItemSelection?.(
directory?.endsWith(separator)
? `${directory}${subDirectory}`
: `${directory}${separator}${subDirectory}`,
true,
);
}
: undefined
}>
<FolderClosedSolid />
{subDirectory}
</li>
)) ?? [];
(directories?.length &&
sort(directories.slice())
.desc((subDirectory) => directoryMatched.includes(subDirectory))
.map((subDirectory) => {
const destination = `${currentDirectory}${subDirectory}${separator}`;
return (
<li
css={[
listItemStyle,
selectable !== 'files' ? listItemSelectableStyle : undefined,
directoryMatched.includes(subDirectory) ? {fontWeight: 'bold'} : undefined,
]}
key={destination}
onClick={selectable !== 'files' ? () => onItemSelection?.(destination, true) : undefined}>
<FolderClosedSolid />
{subDirectory}
</li>
);
})) ||
[];
const filesList: ReactNodeArray =
files?.map((file) => (
<li
className={`${'filesystem__directory-list__item filesystem__directory-list__item--file'.concat(
selectable !== 'directories' ? ' filesystem__directory-list__item--selectable' : '',
)}`}
key={file}
onClick={
selectable !== 'directories'
? () => {
onItemSelection?.(
directory?.endsWith(separator) ? `${directory}${file}` : `${directory}${separator}${file}`,
false,
);
}
: undefined
}>
<File />
{file}
</li>
)) ?? [];
const fileMatched = lastSegment ? termMatch(files, (file) => file, lastSegment) : [];
const fileList: ReactNodeArray =
(files?.length &&
sort(files.slice())
.desc((file) => fileMatched.includes(file))
.map((file) => {
const destination = `${currentDirectory}${file}`;
return (
<li
css={[
listItemStyle,
selectable !== 'directories' ? listItemSelectableStyle : undefined,
fileMatched.includes(file) ? {fontWeight: 'bold'} : undefined,
]}
key={destination}
onClick={selectable !== 'directories' ? () => onItemSelection?.(destination, false) : undefined}>
<File />
{file}
</li>
);
})) ||
[];
listItems = [...directoryList, ...filesList];
}
if (directoryList.length === 0 && fileList.length === 0 && !errorMessage) {
errorMessage = 'filesystem.empty.directory';
}
if ((!listItems || listItems.length === 0) && !errorMessage) {
errorMessage = (
<div className="filesystem__directory-list__item filesystem__directory-list__item--message">
<em>
<Trans id="filesystem.empty.directory" />
</em>
</div>
);
const inputDirectoryElement =
!directoryMatched.includes(lastSegment) && selectable === 'directories' && lastSegment && !errorMessage
? (() => {
const inputDestination = `${currentDirectory}${lastSegment}${separator}`;
return [
<li
css={[
listItemStyle,
listItemSelectableStyle,
{fontWeight: 'bold', '@media (max-width: 720px)': {display: 'none'}},
]}
key={inputDestination}
onClick={() => onItemSelection?.(inputDestination, false)}>
<FolderClosedOutlined />
<span css={{whiteSpace: 'pre-wrap'}}>{lastSegment}</span>
<em css={{fontWeight: 'lighter'}}>
{' - '}
<Trans id="filesystem.error.enoent" />
</em>
</li>,
];
})()
: [];
listItems = [parentDirectoryElement, ...inputDirectoryElement, ...directoryList, ...fileList];
}
return (
<div className="filesystem__directory-list context-menu__items__padding-surrogate">
{parentDirectoryElement}
{errorMessage}
<div
css={{
color: foregroundColor,
listStyle: 'none',
padding: '3px 0px',
'.icon': {
fill: 'currentColor',
height: '14px',
width: '14px',
marginRight: `${25 * (1 / 5)}px`,
marginTop: '-3px',
verticalAlign: 'middle',
},
}}>
{currentDirectory && (
<li
css={[
listItemStyle,
headerStyle,
{
whiteSpace: 'pre-wrap',
'.icon': {
transform: 'scale(0.9)',
marginTop: '-2px !important',
},
'@media (max-width: 720px)': {
display: 'none',
},
},
]}>
<FolderOpenSolid />
{currentDirectory}
</li>
)}
{listItems}
{errorMessage && (
<div css={[listItemStyle, {opacity: 1}]}>
<em>
<Trans id={errorMessage} />
</em>
</div>
)}
</div>
);
},

View File

@@ -139,13 +139,13 @@ const FilesystemBrowserTextbox = forwardRef<HTMLInputElement, FilesystemBrowserT
<FilesystemBrowser
directory={destination}
selectable={selectable}
onItemSelection={(newDestination: string, isDirectory = true) => {
onItemSelection={(newDestination: string, shouldKeepOpen = true) => {
if (textboxRef.current != null) {
textboxRef.current.value = newDestination;
}
setIsDirectoryListOpen(shouldKeepOpen);
setDestination(newDestination);
setIsDirectoryListOpen(isDirectory);
}}
/>
</ContextMenu>

View File

@@ -1,23 +1,34 @@
const termMatch = <T>(elements: Array<T>, sub: (element: T) => string, searchString: string): Array<T> => {
if (searchString !== '') {
const termMatch = <T>(
elements: Array<T> | undefined | null,
sub: (element: T) => string,
searchString: string,
): Array<T> => {
if (searchString !== '' && elements?.length) {
const queries: Array<RegExp> = [];
const searchTerms = searchString.replace(/,/g, ' ').split(' ');
for (let i = 0, len = searchTerms.length; i < len; i += 1) {
queries.push(new RegExp(searchTerms[i], 'gi'));
try {
queries.push(new RegExp(searchTerms[i], 'gi'));
} catch {
// do nothing.
}
}
return elements.filter((element) => {
for (let i = 0, len = queries.length; i < len; i += 1) {
if (!sub(element).match(queries[i])) {
return false;
}
if (sub(element) === searchString) {
return true;
}
return true;
if (queries.every((query) => sub(element).match(query))) {
return true;
}
return false;
});
}
return elements;
return elements ?? [];
};
export default termMatch;

View File

@@ -1,59 +0,0 @@
$filesystem--directory-list--foreground: #5e728c;
$filesystem--directory-list--foreground--hover: saturate(darken($filesystem--directory-list--foreground, 15%), 10%);
$filesystem--directory-list--background--hover: rgba($filesystem--directory-list--foreground, 0.1);
$filesystem--directory-list--parent--border-color: lighten($filesystem--directory-list--foreground, 43%);
.filesystem {
&__directory-list {
color: $filesystem--directory-list--foreground;
list-style: none;
&__item {
opacity: 0.5;
padding: $spacing--xx-small $spacing--small;
transition: color 0.25s;
white-space: nowrap;
&--parent,
&--selectable {
opacity: 1;
cursor: pointer;
transition: background $speed--x-fast, color $speed--x-fast;
user-select: none;
&:hover {
color: $filesystem--directory-list--foreground--hover;
background: $filesystem--directory-list--background--hover;
}
}
&--message {
opacity: 1;
}
&--parent {
border-bottom: 1px solid $filesystem--directory-list--parent--border-color;
margin-bottom: $spacing--small;
padding-bottom: $spacing--small;
opacity: 0.75;
.icon {
transform: scale(0.75) rotate(180deg);
}
&:last-child {
margin-bottom: 0;
}
}
}
.icon {
fill: currentColor;
height: 14px;
width: 14px;
margin-right: $spacing-unit * 1/5;
margin-top: -3px;
vertical-align: middle;
}
}
}

View File

@@ -26,7 +26,6 @@
@import 'components/dropdown';
@import 'components/dropzone';
@import 'components/duration';
@import 'components/filesystem';
@import 'components/floating-action';
@import 'components/icons';
@import 'components/interactive-list';

22
package-lock.json generated
View File

@@ -154,6 +154,7 @@
"parse-torrent": "^9.1.3",
"passport": "^0.4.1",
"passport-jwt": "^4.0.0",
"polished": "^4.1.1",
"postcss": "^8.2.7",
"postcss-loader": "^5.0.0",
"prettier": "^2.2.1",
@@ -14978,6 +14979,18 @@
"integrity": "sha512-6XYcNkXWGiJ2CVXogTP7uJ6ZXQCldYLZc16wgRp8tqRaBTTyIfF+TUT3EQJPXTLAT7OTPpTAoaFdoXKfaTRU1w==",
"dev": true
},
"node_modules/polished": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/polished/-/polished-4.1.1.tgz",
"integrity": "sha512-4MZTrfPMPRLD7ac8b+2JZxei58zw6N1hFkdBDERif5Tlj19y3vPoPusrLG+mJIlPTGnUlKw3+yWz0BazvMx1vg==",
"dev": true,
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=10"
}
},
"node_modules/portfinder": {
"version": "1.0.28",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz",
@@ -36329,6 +36342,15 @@
"integrity": "sha512-6XYcNkXWGiJ2CVXogTP7uJ6ZXQCldYLZc16wgRp8tqRaBTTyIfF+TUT3EQJPXTLAT7OTPpTAoaFdoXKfaTRU1w==",
"dev": true
},
"polished": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/polished/-/polished-4.1.1.tgz",
"integrity": "sha512-4MZTrfPMPRLD7ac8b+2JZxei58zw6N1hFkdBDERif5Tlj19y3vPoPusrLG+mJIlPTGnUlKw3+yWz0BazvMx1vg==",
"dev": true,
"requires": {
"@babel/runtime": "^7.12.5"
}
},
"portfinder": {
"version": "1.0.28",
"resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.28.tgz",

View File

@@ -178,6 +178,7 @@
"parse-torrent": "^9.1.3",
"passport": "^0.4.1",
"passport-jwt": "^4.0.0",
"polished": "^4.1.1",
"postcss": "^8.2.7",
"postcss-loader": "^5.0.0",
"prettier": "^2.2.1",