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'); export interface FilePickerProps { startPath: string; includeFiles?: boolean; includeFolders?: boolean; filter?: RegExp | ((file: File) => boolean); validFileExtensions?: string[]; allowAllFiles?: boolean; defaultHidden?: boolean; max?: number; fileSelType?: FileSelectionType; onSubmit: (val: { path: string; realpath: string }) => void; closeModal?: () => void; } export interface File { isdir: boolean; ishidden: boolean; name: string; realpath: string; size: number; modified: number; created: number; } export enum FileSelectionType { FILE, FOLDER, NONE, } 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, 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, include_folders: includeFolders, include_ext: includeExt ? includeExt : [], include_hidden: includeHidden, order_by: orderBy, filter_for: filterFor, page: pageNumber, max: max, }); } const iconStyles = { paddingRight: '10px', width: '1em', }; const FilePicker: FunctionComponent = ({ startPath, //What are we allowing to show in the file picker includeFiles = true, includeFolders = true, //Parameter for specifying a specific filename match filter = undefined, //Filter for specific extensions as an array validFileExtensions = undefined, //Allow to override the fixed extension above allowAllFiles = true, //If we need to show hidden files and folders (both Win and Linux should work) defaultHidden = false, // false by default makes sense for most users //How much files per page to show, default 1000 max = 1000, //Which picking option to select by default fileSelType = FileSelectionType.NONE, onSubmit, closeModal, }) => { const { t } = useTranslation(); 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, 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 () => { setLoading(true); const listing = await getList( path, includeFiles, includeFolders, selectedExts, showHidden, sort, filter, page, max, ); if (!listing.success) { setListing({ files: [], realpath: path, total: 0 }); setLoading(false); 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); })(); }, [error, path, includeFiles, includeFolders, showHidden, sort, selectedExts, page]); return ( <> { 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.isdir ? t('FilePickerIndex.folder.label') : filesize(file.size, { standard: 'iec' })} {new Date(file.modified * 1000).toLocaleString()} ); })} {error !== FileErrorTypes.None && } {!loading && error === FileErrorTypes.None && fileSelType !== FileSelectionType.NONE && ( { onSubmit({ path, realpath: listing.realpath }); closeModal?.(); }} > {fileSelType === FileSelectionType.FILE ? t('FilePickerIndex.file.select') : t('FilePickerIndex.folder.select')} )} {page * max < listing.total && ( { setPage(page + 1); }} > {t('FilePickerIndex.folder.show_more')} )} > ); }; export default FilePicker;