diff options
| author | Marco Rodolfi <marco.rodolfi@tuta.io> | 2023-06-19 15:23:27 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2023-06-19 06:23:27 -0700 |
| commit | 57f4555350c669e4cb098b48691975be79838468 (patch) | |
| tree | f840889c0c344ed5ae56a0f06398a97ca5982d2b /frontend/src/components | |
| parent | bd87cc852b935369bba04130801467b8dbeb6d1c (diff) | |
| download | decky-loader-57f4555350c669e4cb098b48691975be79838468.tar.gz decky-loader-57f4555350c669e4cb098b48691975be79838468.zip | |
[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
Diffstat (limited to 'frontend/src/components')
7 files changed, 536 insertions, 116 deletions
diff --git a/frontend/src/components/DeckyState.tsx b/frontend/src/components/DeckyState.tsx index 53ef6d2d..920985b3 100644 --- a/frontend/src/components/DeckyState.tsx +++ b/frontend/src/components/DeckyState.tsx @@ -13,6 +13,12 @@ interface PublicDeckyState { hasLoaderUpdate?: boolean; isLoaderUpdating: boolean; versionInfo: VerInfo | null; + userInfo: UserInfo | null; +} + +export interface UserInfo { + username: string; + path: string; } export class DeckyState { @@ -24,6 +30,7 @@ export class DeckyState { private _hasLoaderUpdate: boolean = false; private _isLoaderUpdating: boolean = false; private _versionInfo: VerInfo | null = null; + private _userInfo: UserInfo | null = null; public eventBus = new EventTarget(); @@ -37,6 +44,7 @@ export class DeckyState { hasLoaderUpdate: this._hasLoaderUpdate, isLoaderUpdating: this._isLoaderUpdating, versionInfo: this._versionInfo, + userInfo: this._userInfo, }; } @@ -85,6 +93,11 @@ export class DeckyState { this.notifyUpdate(); } + setUserInfo(userInfo: UserInfo) { + this._userInfo = userInfo; + this.notifyUpdate(); + } + private notifyUpdate() { this.eventBus.dispatchEvent(new Event('update')); } diff --git a/frontend/src/components/modals/DropdownMultiselect.tsx b/frontend/src/components/modals/DropdownMultiselect.tsx new file mode 100644 index 00000000..5defbfa4 --- /dev/null +++ b/frontend/src/components/modals/DropdownMultiselect.tsx @@ -0,0 +1,121 @@ +import { + DialogButton, + DialogCheckbox, + DialogCheckboxProps, + Marquee, + Menu, + MenuItem, + findModuleChild, + showContextMenu, +} from 'decky-frontend-lib'; +import { FC, useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FaChevronDown } from 'react-icons/fa'; + +const dropDownControlButtonClass = findModuleChild((m) => { + if (typeof m !== 'object') return undefined; + for (const prop in m) { + if (m[prop]?.toString()?.includes('gamepaddropdown_DropDownControlButton')) { + return m[prop]; + } + } +}); + +const DropdownMultiselectItem: FC< + { + value: any; + onSelect: (checked: boolean, value: any) => void; + checked: boolean; + } & DialogCheckboxProps +> = ({ value, onSelect, checked: defaultChecked, ...rest }) => { + const [checked, setChecked] = useState(defaultChecked); + + useEffect(() => { + onSelect?.(checked, value); + }, [checked, onSelect, value]); + + return ( + <MenuItem bInteractableItem onClick={() => setChecked((x) => !x)}> + <DialogCheckbox + style={{ marginBottom: 0, padding: 0 }} + className="decky_DropdownMultiselectItem_DialogCheckbox" + bottomSeparator="none" + {...rest} + onClick={() => setChecked((x) => !x)} + onChange={(checked) => setChecked(checked)} + controlled + checked={checked} + /> + </MenuItem> + ); +}; + +const DropdownMultiselect: FC<{ + items: { + label: string; + value: string; + }[]; + selected: string[]; + onSelect: (selected: any[]) => void; + label: string; +}> = ({ label, items, selected, onSelect }) => { + const [itemsSelected, setItemsSelected] = useState<any>(selected); + const { t } = useTranslation(); + + const handleItemSelect = useCallback((checked, value) => { + setItemsSelected((x: any) => + checked ? [...x.filter((y: any) => y !== value), value] : x.filter((y: any) => y !== value), + ); + }, []); + + useEffect(() => { + onSelect(itemsSelected); + }, [itemsSelected, onSelect]); + + return ( + <DialogButton + style={{ + display: 'flex', + alignItems: 'center', + maxWidth: '100%', + }} + className={dropDownControlButtonClass} + onClick={(evt) => { + evt.preventDefault(); + showContextMenu( + <Menu label={label} cancelText={t('DropdownMultiselect.button.back') as string}> + <style> + {` + /* Inherit color from ".basiccontextmenu" */ + .decky_DropdownMultiselectItem_DialogCheckbox > .DialogToggle_Label { + color: inherit; + } + `} + </style> + <div style={{ marginTop: '10px' }}>{/*FIXME: Hack for missing padding under label menu*/}</div> + {items.map((x) => ( + <DropdownMultiselectItem + key={x.value} + label={x.label} + value={x.value} + checked={itemsSelected.includes(x.value)} + onSelect={handleItemSelect} + /> + ))} + </Menu>, + evt.currentTarget ?? window, + ); + }} + > + <Marquee> + {selected.length > 0 + ? selected.map((x: any) => items[items.findIndex((v) => v.value === x)].label).join(', ') + : '…'} + </Marquee> + <div style={{ flexGrow: 1, minWidth: '1ch' }} /> + <FaChevronDown style={{ height: '1em', flex: '0 0 1em' }} /> + </DialogButton> + ); +}; + +export default DropdownMultiselect; diff --git a/frontend/src/components/modals/filepicker/FilePickerError.tsx b/frontend/src/components/modals/filepicker/FilePickerError.tsx new file mode 100644 index 00000000..bf75afae --- /dev/null +++ b/frontend/src/components/modals/filepicker/FilePickerError.tsx @@ -0,0 +1,51 @@ +import { FC, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { IconContext } from 'react-icons'; +import { FaExclamationTriangle, FaQuestionCircle } from 'react-icons/fa'; + +export enum FileErrorTypes { + FileNotFound, + Unknown, + None, +} + +interface FilePickerErrorProps { + error: FileErrorTypes; + rawError?: string; +} + +const FilePickerError: FC<FilePickerErrorProps> = ({ error, rawError = null }) => { + const [icon, setIcon] = useState<JSX.Element>(<FaQuestionCircle />); + const [text, setText] = useState<string | null>(null); + const { t } = useTranslation(); + + useEffect(() => { + switch (error) { + case FileErrorTypes.FileNotFound: + setText(t('FilePickerError.errors.file_not_found')); + setIcon(<FaExclamationTriangle />); + break; + case FileErrorTypes.Unknown: + setText(t('FilePickerError.errors.unknown', { raw_error: rawError })); + setIcon(<FaQuestionCircle />); + break; + case FileErrorTypes.None: + setText(null); + setIcon(<div></div>); + break; + } + }, [error]); + + return ( + <> + <div style={{ paddingTop: '50px', textAlign: 'center', height: '100%' }}> + <IconContext.Provider value={{ className: 'fileError', size: '128px' }}> + <div style={{ alignSelf: 'center', alignContent: 'center' }}>{icon}</div> + </IconContext.Provider> + <p style={{ height: '32px', paddingTop: '25px', alignSelf: 'flex-start', textAlign: 'center' }}>{text}</p> + </div> + </> + ); +}; + +export default FilePickerError; diff --git a/frontend/src/components/modals/filepicker/i18n/TSortOptions.tsx b/frontend/src/components/modals/filepicker/i18n/TSortOptions.tsx new file mode 100644 index 00000000..5d861d18 --- /dev/null +++ b/frontend/src/components/modals/filepicker/i18n/TSortOptions.tsx @@ -0,0 +1,46 @@ +import { FC } from 'react'; +import { Translation } from 'react-i18next'; + +export enum SortOptions { + name_desc = 'name_desc', + name_asc = 'name_asc', + modified_desc = 'modified_desc', + modified_asc = 'modified_asc', + created_desc = 'created_desc', + created_asc = 'created_asc', + size_desc = 'size_desc', + size_asc = 'size_asc', +} + +interface TSortOptionsProps { + trans_part: SortOptions; +} + +const TSortOptions: FC<TSortOptionsProps> = ({ trans_part }) => { + return ( + <Translation> + {(t, {}) => { + switch (trans_part) { + case SortOptions.name_desc: + return t('FilePickerIndex.filter.name_desc'); + case SortOptions.name_asc: + return t('FilePickerIndex.filter.name_asce'); + case SortOptions.modified_desc: + return t('FilePickerIndex.filter.modified_desc'); + case SortOptions.modified_asc: + return t('FilePickerIndex.filter.modified_asce'); + case SortOptions.created_desc: + return t('FilePickerIndex.filter.created_desc'); + case SortOptions.created_asc: + return t('FilePickerIndex.filter.created_asce'); + case SortOptions.size_desc: + return t('FilePickerIndex.filter.size_desc'); + case SortOptions.size_asc: + return t('FilePickerIndex.filter.size_asce'); + } + }} + </Translation> + ); +}; + +export default TSortOptions; diff --git a/frontend/src/components/modals/filepicker/iconCustomizations.ts b/frontend/src/components/modals/filepicker/iconCustomizations.ts index e09c9e67..69e1652d 100644 --- a/frontend/src/components/modals/filepicker/iconCustomizations.ts +++ b/frontend/src/components/modals/filepicker/iconCustomizations.ts @@ -38,7 +38,7 @@ const imageStyle = { color: '#d18f00', }; -const imageExtList = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tif', 'tiff']; +const imageExtList = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tif', 'tiff', 'apng', 'tga']; styleDef.push([imageStyle, imageExtList]); 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: <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> + )} + {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> )} - </div> + </> ); }; diff --git a/frontend/src/components/settings/pages/developer/index.tsx b/frontend/src/components/settings/pages/developer/index.tsx index 200f13ab..3e6db2f7 100644 --- a/frontend/src/components/settings/pages/developer/index.tsx +++ b/frontend/src/components/settings/pages/developer/index.tsx @@ -13,26 +13,24 @@ import { useTranslation } from 'react-i18next'; import { FaFileArchive, FaLink, FaReact, FaSteamSymbol, FaTerminal } from 'react-icons/fa'; import { setShouldConnectToReactDevTools, setShowValveInternal } from '../../../../developer'; +import Logger from '../../../../logger'; import { installFromURL } from '../../../../store'; import { useSetting } from '../../../../utils/hooks/useSetting'; +import { getSetting } from '../../../../utils/settings'; import RemoteDebuggingSettings from '../general/RemoteDebugging'; -const installFromZip = () => { - window.DeckyPluginLoader.openFilePicker('/home/deck', true).then((val) => { +const logger = new Logger('DeveloperIndex'); + +const installFromZip = async () => { + const path = await getSetting<string>('user_info.user_home', ''); + if (path === '') { + logger.error('The default path has not been found!'); + return; + } + window.DeckyPluginLoader.openFilePicker(path, true, undefined, true, ['zip', 'rar'], false, true).then((val) => { const url = `file://${val.path}`; console.log(`Installing plugin locally from ${url}`); - - if (url.endsWith('.zip')) { - installFromURL(url); - } else { - window.DeckyPluginLoader.toaster.toast({ - //title: t('SettingsDeveloperIndex.toast_zip.title'), - title: 'Decky', - //body: t('SettingsDeveloperIndex.toast_zip.body'), - body: 'Installation failed! Only ZIP files are supported.', - onClick: installFromZip, - }); - } + installFromURL(url); }); }; |
