[Feature] File picker improvements (#454)

* First iteration for internationalization of the loader

* First iteration for internationalization of the loader

* Cleanup node mess

* Cleanup node mess pt2

* Additional touches

* Latest decky changed merged into i18n and updated translation.

* Styling fixes

* Initial backend hosting implementation

* Added correct url path of the loopback server.

* Added correct url path of the loopback server.

* Some better namespaced text.

* Added whitelist for locales path.

* Refactor languages and fix hooks logic bugs.

* Small typo in language translation structure.

* Working backend, automatically swtich languages with steam and language fixes.

* Fix to languages

* Key fixes

* Additional language fixes.

* Additional json changes

* Final text revision and added a vscode tasks to automatically extract text from code.

* Typo in the middleware

* Remove unused imports

* Cleanup whitespaces.

* Import changes

* Revert "Import changes"

This reverts commit 8e8231950f.

* Update index.d.ts

* Clean up unused imports

* Delete pnpm-lock.yaml

* Update rollup.config.js

* Update PluginInstallModal.tsx

* Update index.tsx

* Update plugin-loader.tsx

* Update plugin-loader.tsx

* Revert "Delete pnpm-lock.yaml"

This reverts commit 3a39f36f21.

* Additional strings reworks.

* Fixes for issues coming from github merge.

* Fixes for master

* Styling fixes

* Styling pt2

* Missed a few strings in master,

* Styling fixes

* Additional master merge fixes.

* Final cleanup and adaptation to master.

* Final empty language cleanup and few string added

* Small changes to italian translation

* Disabled translation on a few components inside plugin-loader for missing react hooks.

* Fixed passing tag to translation.

* Disable debug output for reducing console spam.

* Return correct content type

* Small italian language change

* Added support for country code

* Fixed missing translation for uninstall popup.

* Fix class name shenanigans for  toast notification

* Update dependencies

* Fixed github workflow to include the new locales folder

* Update dependencies to latest version (unless it's React) and fixed the new small errors that cropped up

* Missed a file name change

* Updated dev dependencies to latest version

* Missed a few dev dependencies

* Revert "Update dependencies to latest version (unless it's React) and fixed the new small errors that cropped up"

Messed up merge with a different main branch

* Messed up deletion of rollup config.

* Fix broken pnpm lock file

* Missed a localized string during the merge

* Fixed a parameter mistake in the uninstall text parameter

* Fix pnpm random issues

* Small italian language tweaks

* Fix wrong parameter passed to the uninstall function call

* Another fix on a wrong function parameter

* Additional translation text on the store and branch selection channels

* Changed the default type passed to map to being able to index the two arrays.

* Reverted and reworked the last changes

* Distinguish events in UI for installing vs reinstalling plugins

* Additional fixes for reinstall prompt

* Revert the use of intevalPlural since the parser doesn't seem to support that.

* Missed a routing path in the backend

* Small bugfixes

* Small fixes

* Correctly adding the parameter to the request headers.

* Refactoring of the UI popup modal

* Fix pnpm shenanigans

* Final fixes for the install UI localization

* Clean up unnedeed backend code

* Small rework on text selection.

* Cleaned up parser configuration

* Removed extracttext dependency to pnpmsetup

* Merged translation and cleaned up parser

* Fixed JSON structure after manual merge.

* Added translation to the file picker

* First iteration for merging the new filepicker.

* Revert changes to PluginInstallModal

* Reworked the text modal for the final time

* Missed the proper linted text

* Missed the backend change

* Final branch cleanup

* First iteration for porting the new file picker

* Hotfix for i18n where the detector was overriding localStorage

* Please, pnpm, cooperate

* Small fix regarding the backend getting hammered when switching to not supported languages plus a small english typo

* Initial working upstream iteration for file picker

* Typo on translation variable

* File picker final improvements

* Stylistic fixes and fix on wrong bool passed to fp

* Fixup merge from main

* Other merge errors fixed

* Minor cleanups

* Fixed missing padding under text label extension

* Implement pagination backend side

* First draft for filtering backend side

* Implemented matching on file names.

* Fix for unable to order per size on folders.

* Hard checking a return value

* Added a missing import.

* Implemented show more as a frontend button

* Whoops, python typo

* Fixed python backend

* Rendering bug fix and small qol improvement

* Added missing parameter to openFilePicker call

* Fixed path on windows and unknown error on wrong path

* Small backend fixes

* Extension fix

* Simplified extension logic

* Less string conversions.

* Optimize backend code and removed additional components.

* Take correctly into account the max value

The button will now respect the actual maximum desired number of entries.

* Bugfix for ordering logic and ignore cases during sorting

* Regex call was missing an argument

* Fixed issues with filtering extensions

* Rollback testing changes

* Minor cleanup and attempt at fixing the not updating multimodal.

* Cleanup variable types.

* Mantains the same api format from the original source code.

* Removing hardcoded paths in the code

* Additional fixes for resolving the user path

* Cleanup useless modifications

* Final fixes for avoid path hardcoding

* Update lockfile and i18next version
This commit is contained in:
Marco Rodolfi
2023-06-19 15:23:27 +02:00
committed by GitHub
parent bd87cc852b
commit 57f4555350
14 changed files with 1069 additions and 491 deletions
@@ -1,11 +1,26 @@
import { DialogButton, Focusable, SteamSpinner, TextField } from 'decky-frontend-lib';
import { useEffect } from 'react';
import { FunctionComponent, useState } from 'react';
import {
ControlsList,
DialogBody,
DialogButton,
DialogControlsSection,
DialogFooter,
Dropdown,
Focusable,
Marquee,
SteamSpinner,
TextField,
ToggleField,
} from 'decky-frontend-lib';
import { filesize } from 'filesize';
import { FunctionComponent, useCallback, useEffect, useMemo, useState } from 'react';
import { FileIcon, defaultStyles } from 'react-file-icon';
import { useTranslation } from 'react-i18next';
import { FaArrowUp, FaFolder } from 'react-icons/fa';
import Logger from '../../../logger';
import DropdownMultiselect from '../DropdownMultiselect';
import FilePickerError, { FileErrorTypes } from './FilePickerError';
import TSortOption, { SortOptions } from './i18n/TSortOptions';
import { styleDefObj } from './iconCustomizations';
const logger = new Logger('FilePicker');
@@ -13,27 +28,89 @@ const logger = new Logger('FilePicker');
export interface FilePickerProps {
startPath: string;
includeFiles?: boolean;
regex?: RegExp;
includeFolders?: boolean;
filter?: RegExp | ((file: File) => boolean);
validFileExtensions?: string[];
allowAllFiles?: boolean;
defaultHidden?: boolean;
max?: number;
onSubmit: (val: { path: string; realpath: string }) => void;
closeModal?: () => void;
}
interface File {
export interface File {
isdir: boolean;
ishidden: boolean;
name: string;
realpath: string;
size: number;
modified: number;
created: number;
}
interface FileListing {
realpath: string;
files: File[];
total: number;
}
const sortOptions = [
{
data: SortOptions.name_desc,
label: <TSortOption trans_part={SortOptions.name_desc} />,
},
{
data: SortOptions.name_asc,
label: <TSortOption trans_part={SortOptions.name_asc} />,
},
{
data: SortOptions.modified_desc,
label: <TSortOption trans_part={SortOptions.modified_desc} />,
},
{
data: SortOptions.modified_asc,
label: <TSortOption trans_part={SortOptions.modified_asc} />,
},
{
data: SortOptions.created_desc,
label: <TSortOption trans_part={SortOptions.created_desc} />,
},
{
data: SortOptions.created_asc,
label: <TSortOption trans_part={SortOptions.created_asc} />,
},
{
data: SortOptions.size_desc,
label: <TSortOption trans_part={SortOptions.size_desc} />,
},
{
data: SortOptions.size_asc,
label: <TSortOption trans_part={SortOptions.size_asc} />,
},
];
function getList(
path: string,
includeFiles: boolean = true,
includeFiles: boolean,
includeFolders: boolean = true,
includeExt: string[] | null = null,
includeHidden: boolean = false,
orderBy: SortOptions = SortOptions.name_desc,
filterFor: RegExp | ((file: File) => boolean) | null = null,
pageNumber: number = 1,
max: number = 1000,
): Promise<{ result: FileListing | string; success: boolean }> {
return window.DeckyPluginLoader.callServerMethod('filepicker_ls', { path, include_files: includeFiles });
return window.DeckyPluginLoader.callServerMethod('filepicker_ls', {
path,
include_files: includeFiles,
include_folders: includeFolders,
include_ext: includeExt ? includeExt : [],
include_hidden: includeHidden,
order_by: orderBy,
filter_for: filterFor,
page: pageNumber,
max: max,
});
}
const iconStyles = {
@@ -44,126 +121,240 @@ const iconStyles = {
const FilePicker: FunctionComponent<FilePickerProps> = ({
startPath,
includeFiles = true,
regex,
filter = undefined,
includeFolders = true,
validFileExtensions = undefined,
allowAllFiles = true,
defaultHidden = false, // false by default makes sense for most users
max = 1000,
onSubmit,
closeModal,
}) => {
const { t } = useTranslation();
if (startPath.endsWith('/')) startPath = startPath.substring(0, startPath.length - 1); // remove trailing path
if (startPath !== '/' && startPath.endsWith('/')) startPath = startPath.substring(0, startPath.length - 1); // remove trailing path
const [path, setPath] = useState<string>(startPath);
const [listing, setListing] = useState<FileListing>({ files: [], realpath: path });
const [error, setError] = useState<string | null>(null);
const [listing, setListing] = useState<FileListing>({ files: [], realpath: path, total: 0 });
const [files, setFiles] = useState<File[]>([]);
const [error, setError] = useState<FileErrorTypes>(FileErrorTypes.None);
const [rawError, setRawError] = useState<string | null>(null);
const [page, setPage] = useState<number>(1);
const [loading, setLoading] = useState<boolean>(true);
const [showHidden, setShowHidden] = useState<boolean>(defaultHidden);
const [sort, setSort] = useState<SortOptions>(SortOptions.name_desc);
const [selectedExts, setSelectedExts] = useState<string[] | undefined>(validFileExtensions);
const validExtsOptions = useMemo(() => {
let validExt: { label: string; value: string }[] = [];
if (validFileExtensions) {
if (allowAllFiles) {
validExt.push({ label: t('FilePickerIndex.files.all_files'), value: 'all_files' });
}
validExt.push(...validFileExtensions.map((x) => ({ label: x, value: x })));
}
return validExt;
}, [validFileExtensions, allowAllFiles]);
function isSelectionValid(validExts: string[], selection: string[]) {
if (validExts.some((el) => selection.includes(el))) return true;
return false;
}
const handleExtsSelect = useCallback((val: any) => {
// unselect other options if "All Files" is checked
if (allowAllFiles && val.includes('all_files')) {
setSelectedExts(['all_files']);
} else if (validFileExtensions && isSelectionValid(validFileExtensions, val)) {
// If at least one extension is still selected, then assign this selection to the selected values
setSelectedExts(val);
} else {
// Else do nothing
setSelectedExts(selectedExts);
}
}, []);
useEffect(() => {
(async () => {
if (error) setError(null);
setLoading(true);
const listing = await getList(path, includeFiles);
const listing = await getList(
path,
includeFiles,
includeFolders,
selectedExts,
showHidden,
sort,
filter,
page,
max,
);
if (!listing.success) {
setListing({ files: [], realpath: path });
setListing({ files: [], realpath: path, total: 0 });
setLoading(false);
setError(listing.result as string);
logger.error(listing.result);
const theError = listing.result as string;
switch (theError) {
case theError.match(/\[Errno\s2.*/i)?.input:
case theError.match(/\[WinError\s3.*/i)?.input:
setError(FileErrorTypes.FileNotFound);
break;
default:
setRawError(theError);
setError(FileErrorTypes.Unknown);
break;
}
logger.debug(theError);
return;
} else {
setRawError(null);
setError(FileErrorTypes.None);
setFiles((listing.result as FileListing).files);
}
setLoading(false);
setListing(listing.result as FileListing);
logger.log('reloaded', path, listing);
})();
}, [path]);
}, [error, path, includeFiles, includeFolders, showHidden, sort, selectedExts, page]);
return (
<div className="deckyFilePicker">
<Focusable style={{ display: 'flex', flexDirection: 'row', paddingBottom: '10px' }}>
<DialogButton
style={{
minWidth: 'unset',
width: '40px',
flexGrow: '0',
borderRadius: 'unset',
margin: '0',
padding: '10px',
}}
onClick={() => {
const newPathArr = path.split('/');
newPathArr.pop();
let newPath = newPathArr.join('/');
if (newPath == '') newPath = '/';
setPath(newPath);
}}
>
<FaArrowUp />
</DialogButton>
<div style={{ flexGrow: '1', width: '100%' }}>
<TextField
value={path}
onChange={(e) => {
e.target.value && setPath(e.target.value);
}}
style={{ height: '100%' }}
/>
</div>
</Focusable>
<Focusable style={{ display: 'flex', flexDirection: 'column', height: '60vh', overflow: 'scroll' }}>
{loading && <SteamSpinner style={{ height: '100%' }} />}
{!loading &&
listing.files
.filter((file) => (includeFiles || file.isdir) && (!regex || regex.test(file.name)))
.map((file) => {
let extension = file.realpath.split('.').pop() as string;
return (
<DialogButton
style={{ borderRadius: 'unset', margin: '0', padding: '10px' }}
onClick={() => {
const fullPath = `${path}${path.endsWith('/') ? '' : '/'}${file.name}`;
if (file.isdir) setPath(fullPath);
else {
onSubmit({ path: fullPath, realpath: file.realpath });
closeModal?.();
}
}}
>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-start' }}>
{file.isdir ? (
<FaFolder style={iconStyles} />
) : (
<div style={iconStyles}>
{file.realpath.includes('.') ? (
<FileIcon {...defaultStyles[extension]} {...styleDefObj[extension]} extension={''} />
) : (
<FileIcon />
)}
</div>
)}
<span
<>
<DialogBody className="deckyFilePicker">
<DialogControlsSection>
<Focusable flow-children="right" style={{ display: 'flex', marginBottom: '1em' }}>
<DialogButton
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: 'unset',
width: '40px',
borderRadius: 'unset',
margin: '0',
padding: '10px',
}}
onClick={() => {
const newPathArr = path.split('/');
const lastPath = newPathArr.pop();
//If I have a single / with spaces, pop the array twice
if (lastPath?.match(/^\/\s*$/) != null) newPathArr.pop();
let newPath = newPathArr.join('/');
if (newPath == '') newPath = '/';
setPath(newPath);
}}
>
<FaArrowUp />
</DialogButton>
<div style={{ width: '100%' }}>
<TextField
value={path}
onChange={(e) => {
e.target.value && setPath(e.target.value);
}}
style={{ height: '100%' }}
/>
</div>
</Focusable>
<ControlsList alignItems="center" spacing="standard">
<ToggleField
highlightOnFocus={false}
label={t('FilePickerIndex.files.show_hidden')}
bottomSeparator="none"
checked={showHidden}
onChange={() => setShowHidden((x) => !x)}
/>
<Dropdown rgOptions={sortOptions} selectedOption={sort} onChange={(x) => setSort(x.data)} />
{validFileExtensions && (
<DropdownMultiselect
label={t('FilePickerIndex.files.file_type')}
items={validExtsOptions}
selected={selectedExts ? selectedExts : []}
onSelect={handleExtsSelect}
/>
)}
</ControlsList>
</DialogControlsSection>
<DialogControlsSection style={{ marginTop: '1em' }}>
<Focusable
style={{ display: 'flex', gap: '.25em', flexDirection: 'column', height: '60vh', overflow: 'scroll' }}
>
{loading && error === FileErrorTypes.None && <SteamSpinner style={{ height: '100%' }} />}
{!loading &&
error === FileErrorTypes.None &&
files.map((file) => {
const extension = file.realpath.split('.').pop() as string;
return (
<DialogButton
key={`${file.realpath}${file.name}`}
style={{ borderRadius: 'unset', margin: '0', padding: '10px' }}
onClick={() => {
const fullPath = `${path}${path.endsWith('/') ? '' : '/'}${file.name}`;
if (file.isdir) setPath(fullPath);
else {
onSubmit({ path: fullPath, realpath: file.realpath });
closeModal?.();
}
}}
>
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'flex-start' }}>
{file.isdir ? (
<FaFolder style={iconStyles} />
) : (
<div style={iconStyles}>
{file.realpath.includes('.') ? (
<FileIcon {...defaultStyles[extension]} {...styleDefObj[extension]} extension={''} />
) : (
<FileIcon />
)}
</div>
)}
<Marquee>{file.name}</Marquee>
</div>
<div
style={{
textOverflow: 'ellipsis',
overflow: 'hidden',
display: 'flex',
opacity: 0.5,
fontSize: '.6em',
textAlign: 'left',
lineHeight: 1,
marginTop: '.5em',
}}
>
{file.name}
</span>
</div>
</DialogButton>
);
})}
{error}
</Focusable>
{file.isdir ? t('FilePickerIndex.folder.label') : filesize(file.size, { standard: 'iec' })}
<span style={{ marginLeft: 'auto' }}>{new Date(file.modified * 1000).toLocaleString()}</span>
</div>
</DialogButton>
);
})}
{error !== FileErrorTypes.None && <FilePickerError error={error} rawError={rawError ? rawError : ''} />}
</Focusable>
</DialogControlsSection>
</DialogBody>
{!loading && !error && !includeFiles && (
<DialogButton
className="Primary"
style={{ marginTop: '10px', alignSelf: 'flex-end' }}
onClick={() => {
onSubmit({ path, realpath: listing.realpath });
closeModal?.();
}}
>
{t('FilePickerIndex.folder.select')}
</DialogButton>
<DialogFooter>
<DialogButton
className="Primary"
style={{ marginTop: '10px', alignSelf: 'flex-end' }}
onClick={() => {
onSubmit({ path, realpath: listing.realpath });
closeModal?.();
}}
>
{t('FilePickerIndex.folder.select')}
</DialogButton>
</DialogFooter>
)}
</div>
{page * max < listing.total && (
<DialogFooter>
<DialogButton
className="Primary"
style={{ marginTop: '10px', alignSelf: 'flex-end' }}
onClick={() => {
setPage(page + 1);
}}
>
{t('FilePickerIndex.folder.show_more')}
</DialogButton>
</DialogFooter>
)}
</>
);
};