From 57f4555350c669e4cb098b48691975be79838468 Mon Sep 17 00:00:00 2001 From: Marco Rodolfi Date: Mon, 19 Jun 2023 15:23:27 +0200 Subject: [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 8e8231950fd7cc6cece87040e326d0a72ba79567. * 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 3a39f36f2193cc976d36ffe07338239e363d5b04. * 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 --- .../src/components/modals/filepicker/index.tsx | 393 +++++++++++++++------ 1 file changed, 292 insertions(+), 101 deletions(-) (limited to 'frontend/src/components/modals/filepicker/index.tsx') diff --git a/frontend/src/components/modals/filepicker/index.tsx b/frontend/src/components/modals/filepicker/index.tsx index 629f4ec5..7aada5a5 100644 --- a/frontend/src/components/modals/filepicker/index.tsx +++ b/frontend/src/components/modals/filepicker/index.tsx @@ -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: , + }, + { + data: SortOptions.name_asc, + label: , + }, + { + data: SortOptions.modified_desc, + label: , + }, + { + data: SortOptions.modified_asc, + label: , + }, + { + data: SortOptions.created_desc, + label: , + }, + { + data: SortOptions.created_asc, + label: , + }, + { + data: SortOptions.size_desc, + label: , + }, + { + data: SortOptions.size_asc, + label: , + }, +]; + 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 = ({ 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(startPath); - const [listing, setListing] = useState({ files: [], realpath: path }); - const [error, setError] = useState(null); + const [listing, setListing] = useState({ files: [], realpath: path, total: 0 }); + const [files, setFiles] = useState([]); + const [error, setError] = useState(FileErrorTypes.None); + const [rawError, setRawError] = useState(null); + const [page, setPage] = useState(1); const [loading, setLoading] = useState(true); + const [showHidden, setShowHidden] = useState(defaultHidden); + const [sort, setSort] = useState(SortOptions.name_desc); + const [selectedExts, setSelectedExts] = useState(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 ( -
- - { - const newPathArr = path.split('/'); - newPathArr.pop(); - let newPath = newPathArr.join('/'); - if (newPath == '') newPath = '/'; - setPath(newPath); - }} - > - - -
- { - e.target.value && setPath(e.target.value); - }} - style={{ height: '100%' }} - /> -
-
- - {loading && } - {!loading && - listing.files - .filter((file) => (includeFiles || file.isdir) && (!regex || regex.test(file.name))) - .map((file) => { - let extension = file.realpath.split('.').pop() as string; - return ( - { - const fullPath = `${path}${path.endsWith('/') ? '' : '/'}${file.name}`; - if (file.isdir) setPath(fullPath); - else { - onSubmit({ path: fullPath, realpath: file.realpath }); - closeModal?.(); - } - }} - > -
- {file.isdir ? ( - - ) : ( -
- {file.realpath.includes('.') ? ( - - ) : ( - - )} -
- )} - + + + + { + 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); + }} + > + + +
+ { + e.target.value && setPath(e.target.value); + }} + style={{ height: '100%' }} + /> +
+
+ + setShowHidden((x) => !x)} + /> + setSort(x.data)} /> + {validFileExtensions && ( + + )} + +
+ + + {loading && error === FileErrorTypes.None && } + {!loading && + error === FileErrorTypes.None && + files.map((file) => { + const extension = file.realpath.split('.').pop() as string; + return ( + { + const fullPath = `${path}${path.endsWith('/') ? '' : '/'}${file.name}`; + if (file.isdir) setPath(fullPath); + else { + onSubmit({ path: fullPath, realpath: file.realpath }); + closeModal?.(); + } + }} + > +
+ {file.isdir ? ( + + ) : ( +
+ {file.realpath.includes('.') ? ( + + ) : ( + + )} +
+ )} + {file.name} +
+
- {file.name} - -
-
- ); - })} - {error} -
+ {file.isdir ? t('FilePickerIndex.folder.label') : filesize(file.size, { standard: 'iec' })} + {new Date(file.modified * 1000).toLocaleString()} +
+
+ ); + })} + {error !== FileErrorTypes.None && } +
+ + {!loading && !error && !includeFiles && ( - { - onSubmit({ path, realpath: listing.realpath }); - closeModal?.(); - }} - > - {t('FilePickerIndex.folder.select')} - + + { + onSubmit({ path, realpath: listing.realpath }); + closeModal?.(); + }} + > + {t('FilePickerIndex.folder.select')} + + + )} + {page * max < listing.total && ( + + { + setPage(page + 1); + }} + > + {t('FilePickerIndex.folder.show_more')} + + )} -
+ ); }; -- cgit v1.2.3