mirror of
https://github.com/SteamDeckHomebrew/decky-loader.git
synced 2026-07-12 01:11:59 +00:00
[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 commit8e8231950f. * 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 commit3a39f36f21. * 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:
@@ -12,9 +12,37 @@
|
|||||||
"disabling": "Disabling React DevTools",
|
"disabling": "Disabling React DevTools",
|
||||||
"enabling": "Enabling React DevTools"
|
"enabling": "Enabling React DevTools"
|
||||||
},
|
},
|
||||||
|
"DropdownMultiselect": {
|
||||||
|
"button": {
|
||||||
|
"back": "Back"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FilePickerError": {
|
||||||
|
"errors": {
|
||||||
|
"file_not_found": "The path specified is not valid. Please check it and reenter it correctly.",
|
||||||
|
"unknown": "An unknown error occurred. The raw error is: {{raw_error}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"FilePickerIndex": {
|
"FilePickerIndex": {
|
||||||
|
"files": {
|
||||||
|
"all_files": "All Files",
|
||||||
|
"file_type": "File Type",
|
||||||
|
"show_hidden": "Show Hidden Files"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"created_asce": "Created (Oldest)",
|
||||||
|
"created_desc": "Created (Newest)",
|
||||||
|
"modified_asce": "Modified (Oldest)",
|
||||||
|
"modified_desc": "Modified (Newest)",
|
||||||
|
"name_asce": "Z-A",
|
||||||
|
"name_desc": "A-Z",
|
||||||
|
"size_asce": "Size (Smallest)",
|
||||||
|
"size_desc": "Size (Largest)"
|
||||||
|
},
|
||||||
"folder": {
|
"folder": {
|
||||||
"select": "Use this folder"
|
"label": "Folder",
|
||||||
|
"select": "Use this folder",
|
||||||
|
"show_more": "Show more files"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"PluginView": {
|
"PluginView": {
|
||||||
|
|||||||
@@ -12,9 +12,37 @@
|
|||||||
"disabling": "Disabilito i tools di React",
|
"disabling": "Disabilito i tools di React",
|
||||||
"enabling": "Abilito i tools di React"
|
"enabling": "Abilito i tools di React"
|
||||||
},
|
},
|
||||||
|
"DropdownMultiselect": {
|
||||||
|
"button": {
|
||||||
|
"back": "Indietro"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"FilePickerError": {
|
||||||
|
"errors": {
|
||||||
|
"file_not_found": "Il percorso specificato non è valido. Controllalo e prova a reinserirlo di nuovo.",
|
||||||
|
"unknown": "È avvenuto un'errore sconosciuto. L'errore segnalato è {{raw_error}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
"FilePickerIndex": {
|
"FilePickerIndex": {
|
||||||
|
"files": {
|
||||||
|
"all_files": "Tutti i file",
|
||||||
|
"file_type": "Tipo di file",
|
||||||
|
"show_hidden": "Mostra nascosti"
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"created_asce": "Creazione (meno recente)",
|
||||||
|
"created_desc": "Creazione (più recente)",
|
||||||
|
"modified_asce": "Modifica (meno recente)",
|
||||||
|
"modified_desc": "Modifica (più recente)",
|
||||||
|
"name_asce": "Z-A",
|
||||||
|
"name_desc": "A-Z",
|
||||||
|
"size_asce": "Dimensione (più piccolo)",
|
||||||
|
"size_desc": "Dimensione (più grande)"
|
||||||
|
},
|
||||||
"folder": {
|
"folder": {
|
||||||
"select": "Usa questa cartella"
|
"label": "Cartella",
|
||||||
|
"select": "Usa questa cartella",
|
||||||
|
"show_more": "Mostra più file"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"PluginCard": {
|
"PluginCard": {
|
||||||
|
|||||||
+84
-21
@@ -1,16 +1,21 @@
|
|||||||
import uuid
|
import uuid
|
||||||
import os
|
import os
|
||||||
from json.decoder import JSONDecodeError
|
from json.decoder import JSONDecodeError
|
||||||
|
from os.path import splitext
|
||||||
|
import re
|
||||||
from traceback import format_exc
|
from traceback import format_exc
|
||||||
|
from stat import FILE_ATTRIBUTE_HIDDEN
|
||||||
|
|
||||||
from asyncio import sleep, start_server, gather, open_connection
|
from asyncio import sleep, start_server, gather, open_connection
|
||||||
from aiohttp import ClientSession, web
|
from aiohttp import ClientSession, web
|
||||||
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from injector import inject_to_tab, get_gamepadui_tab, close_old_tabs, get_tab
|
from injector import inject_to_tab, get_gamepadui_tab, close_old_tabs, get_tab
|
||||||
|
from pathlib import Path
|
||||||
|
from localplatform import ON_WINDOWS
|
||||||
import helpers
|
import helpers
|
||||||
import subprocess
|
import subprocess
|
||||||
from localplatform import service_stop, service_start
|
from localplatform import service_stop, service_start, get_home_path, get_username
|
||||||
|
|
||||||
class Utilities:
|
class Utilities:
|
||||||
def __init__(self, context) -> None:
|
def __init__(self, context) -> None:
|
||||||
@@ -33,7 +38,8 @@ class Utilities:
|
|||||||
"filepicker_ls": self.filepicker_ls,
|
"filepicker_ls": self.filepicker_ls,
|
||||||
"disable_rdt": self.disable_rdt,
|
"disable_rdt": self.disable_rdt,
|
||||||
"enable_rdt": self.enable_rdt,
|
"enable_rdt": self.enable_rdt,
|
||||||
"get_tab_id": self.get_tab_id
|
"get_tab_id": self.get_tab_id,
|
||||||
|
"get_user_info": self.get_user_info,
|
||||||
}
|
}
|
||||||
|
|
||||||
self.logger = getLogger("Utilities")
|
self.logger = getLogger("Utilities")
|
||||||
@@ -189,32 +195,83 @@ class Utilities:
|
|||||||
await service_stop(helpers.REMOTE_DEBUGGER_UNIT)
|
await service_stop(helpers.REMOTE_DEBUGGER_UNIT)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def filepicker_ls(self, path, include_files=True):
|
async def filepicker_ls(self,
|
||||||
# def sorter(file): # Modification time
|
path : str | None = None,
|
||||||
# if os.path.isdir(os.path.join(path, file)) or os.path.isfile(os.path.join(path, file)):
|
include_files: bool = True,
|
||||||
# return os.path.getmtime(os.path.join(path, file))
|
include_folders: bool = True,
|
||||||
# return 0
|
include_ext: list[str] = [],
|
||||||
# file_names = sorted(os.listdir(path), key=sorter, reverse=True) # TODO provide more sort options
|
include_hidden: bool = False,
|
||||||
file_names = sorted(os.listdir(path)) # Alphabetical
|
order_by: str = "name_asc",
|
||||||
|
filter_for: str | None = None,
|
||||||
|
page: int = 1,
|
||||||
|
max: int = 1000):
|
||||||
|
|
||||||
files = []
|
if path == None:
|
||||||
|
path = get_home_path()
|
||||||
|
|
||||||
for file in file_names:
|
path = Path(path).resolve()
|
||||||
full_path = os.path.join(path, file)
|
|
||||||
is_dir = os.path.isdir(full_path)
|
|
||||||
|
|
||||||
if is_dir or include_files:
|
files, folders = [], []
|
||||||
files.append({
|
|
||||||
"isdir": is_dir,
|
#Resolving all files/folders in the requested directory
|
||||||
"name": file,
|
for file in path.iterdir():
|
||||||
"realpath": os.path.realpath(full_path)
|
if file.exists():
|
||||||
})
|
filest = file.stat()
|
||||||
|
is_hidden = file.name.startswith('.')
|
||||||
|
if ON_WINDOWS and not is_hidden:
|
||||||
|
is_hidden = bool(filest.st_file_attributes & FILE_ATTRIBUTE_HIDDEN)
|
||||||
|
if include_folders and file.is_dir():
|
||||||
|
if (is_hidden and include_hidden) or not is_hidden:
|
||||||
|
folders.append({"file": file, "filest": filest, "is_dir": True})
|
||||||
|
elif include_files:
|
||||||
|
# Handle requested extensions if present
|
||||||
|
if 'all_files' in include_ext or splitext(file.name)[1].lstrip('.') in include_ext:
|
||||||
|
if (is_hidden and include_hidden) or not is_hidden:
|
||||||
|
files.append({"file": file, "filest": filest, "is_dir": False})
|
||||||
|
# Filter logic
|
||||||
|
if filter_for is not None:
|
||||||
|
try:
|
||||||
|
if re.compile(filter_for):
|
||||||
|
files = filter(lambda file: re.search(filter_for, file.name) != None, files)
|
||||||
|
except re.error:
|
||||||
|
files = filter(lambda file: file.name.find(filter_for) != -1, files)
|
||||||
|
|
||||||
|
# Ordering logic
|
||||||
|
ord_arg = order_by.split("_")
|
||||||
|
ord = ord_arg[0]
|
||||||
|
rev = True if ord_arg[1] == "asc" else False
|
||||||
|
match ord:
|
||||||
|
case 'name':
|
||||||
|
files.sort(key=lambda x: x['file'].name.casefold(), reverse = rev)
|
||||||
|
folders.sort(key=lambda x: x['file'].name.casefold(), reverse = rev)
|
||||||
|
case 'modified':
|
||||||
|
files.sort(key=lambda x: x['filest'].st_mtime, reverse = not rev)
|
||||||
|
folders.sort(key=lambda x: x['filest'].st_mtime, reverse = not rev)
|
||||||
|
case 'created':
|
||||||
|
files.sort(key=lambda x: x['filest'].st_ctime, reverse = not rev)
|
||||||
|
folders.sort(key=lambda x: x['filest'].st_ctime, reverse = not rev)
|
||||||
|
case 'size':
|
||||||
|
files.sort(key=lambda x: x['filest'].st_size, reverse = not rev)
|
||||||
|
# Folders has no file size, order by name instead
|
||||||
|
folders.sort(key=lambda x: x['file'].name.casefold())
|
||||||
|
|
||||||
|
#Constructing the final file list, folders first
|
||||||
|
all = [{
|
||||||
|
"isdir": x['is_dir'],
|
||||||
|
"name": str(x['file'].name),
|
||||||
|
"realpath": str(x['file']),
|
||||||
|
"size": x['filest'].st_size,
|
||||||
|
"modified": x['filest'].st_mtime,
|
||||||
|
"created": x['filest'].st_ctime,
|
||||||
|
} for x in folders + files ]
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"realpath": os.path.realpath(path),
|
"realpath": str(path),
|
||||||
"files": files
|
"files": all[(page-1)*max:(page)*max],
|
||||||
|
"total": len(all),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# Based on https://stackoverflow.com/a/46422554/13174603
|
# Based on https://stackoverflow.com/a/46422554/13174603
|
||||||
def start_rdt_proxy(self, ip, port):
|
def start_rdt_proxy(self, ip, port):
|
||||||
async def pipe(reader, writer):
|
async def pipe(reader, writer):
|
||||||
@@ -289,5 +346,11 @@ class Utilities:
|
|||||||
await tab.evaluate_js("location.reload();", False, True, False)
|
await tab.evaluate_js("location.reload();", False, True, False)
|
||||||
self.logger.info("React DevTools disabled")
|
self.logger.info("React DevTools disabled")
|
||||||
|
|
||||||
|
async def get_user_info(self) -> dict:
|
||||||
|
return {
|
||||||
|
"username": get_username(),
|
||||||
|
"path": get_home_path()
|
||||||
|
}
|
||||||
|
|
||||||
async def get_tab_id(self, name):
|
async def get_tab_id(self, name):
|
||||||
return (await get_tab(name)).id
|
return (await get_tab(name)).id
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
"@types/react-router": "5.1.18",
|
"@types/react-router": "5.1.18",
|
||||||
"@types/webpack": "^5.28.1",
|
"@types/webpack": "^5.28.1",
|
||||||
"husky": "^8.0.3",
|
"husky": "^8.0.3",
|
||||||
"i18next-parser": "^7.9.0",
|
"i18next-parser": "^8.0.0",
|
||||||
"import-sort-style-module": "^6.0.0",
|
"import-sort-style-module": "^6.0.0",
|
||||||
"inquirer": "^8.2.5",
|
"inquirer": "^8.2.5",
|
||||||
"prettier": "^2.8.8",
|
"prettier": "^2.8.8",
|
||||||
@@ -33,8 +33,8 @@
|
|||||||
"rollup-plugin-delete": "^2.0.0",
|
"rollup-plugin-delete": "^2.0.0",
|
||||||
"rollup-plugin-external-globals": "^0.6.1",
|
"rollup-plugin-external-globals": "^0.6.1",
|
||||||
"rollup-plugin-polyfill-node": "^0.10.2",
|
"rollup-plugin-polyfill-node": "^0.10.2",
|
||||||
"rollup-plugin-visualizer": "^5.9.0",
|
"rollup-plugin-visualizer": "^5.9.2",
|
||||||
"tslib": "^2.5.2",
|
"tslib": "^2.5.3",
|
||||||
"typescript": "^4.9.5"
|
"typescript": "^4.9.5"
|
||||||
},
|
},
|
||||||
"importSort": {
|
"importSort": {
|
||||||
@@ -45,11 +45,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"decky-frontend-lib": "3.21.1",
|
"decky-frontend-lib": "3.21.1",
|
||||||
"i18next": "^22.5.0",
|
"filesize": "^10.0.7",
|
||||||
|
"i18next": "^23.1.0",
|
||||||
"i18next-http-backend": "^2.2.1",
|
"i18next-http-backend": "^2.2.1",
|
||||||
"react-file-icon": "^1.3.0",
|
"react-file-icon": "^1.3.0",
|
||||||
"react-i18next": "^12.3.1",
|
"react-i18next": "^12.3.1",
|
||||||
"react-icons": "^4.8.0",
|
"react-icons": "^4.9.0",
|
||||||
"react-markdown": "^8.0.7",
|
"react-markdown": "^8.0.7",
|
||||||
"remark-gfm": "^3.0.1"
|
"remark-gfm": "^3.0.1"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+359
-341
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,12 @@ interface PublicDeckyState {
|
|||||||
hasLoaderUpdate?: boolean;
|
hasLoaderUpdate?: boolean;
|
||||||
isLoaderUpdating: boolean;
|
isLoaderUpdating: boolean;
|
||||||
versionInfo: VerInfo | null;
|
versionInfo: VerInfo | null;
|
||||||
|
userInfo: UserInfo | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
username: string;
|
||||||
|
path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DeckyState {
|
export class DeckyState {
|
||||||
@@ -24,6 +30,7 @@ export class DeckyState {
|
|||||||
private _hasLoaderUpdate: boolean = false;
|
private _hasLoaderUpdate: boolean = false;
|
||||||
private _isLoaderUpdating: boolean = false;
|
private _isLoaderUpdating: boolean = false;
|
||||||
private _versionInfo: VerInfo | null = null;
|
private _versionInfo: VerInfo | null = null;
|
||||||
|
private _userInfo: UserInfo | null = null;
|
||||||
|
|
||||||
public eventBus = new EventTarget();
|
public eventBus = new EventTarget();
|
||||||
|
|
||||||
@@ -37,6 +44,7 @@ export class DeckyState {
|
|||||||
hasLoaderUpdate: this._hasLoaderUpdate,
|
hasLoaderUpdate: this._hasLoaderUpdate,
|
||||||
isLoaderUpdating: this._isLoaderUpdating,
|
isLoaderUpdating: this._isLoaderUpdating,
|
||||||
versionInfo: this._versionInfo,
|
versionInfo: this._versionInfo,
|
||||||
|
userInfo: this._userInfo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +93,11 @@ export class DeckyState {
|
|||||||
this.notifyUpdate();
|
this.notifyUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setUserInfo(userInfo: UserInfo) {
|
||||||
|
this._userInfo = userInfo;
|
||||||
|
this.notifyUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
private notifyUpdate() {
|
private notifyUpdate() {
|
||||||
this.eventBus.dispatchEvent(new Event('update'));
|
this.eventBus.dispatchEvent(new Event('update'));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -38,7 +38,7 @@ const imageStyle = {
|
|||||||
color: '#d18f00',
|
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]);
|
styleDef.push([imageStyle, imageExtList]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,26 @@
|
|||||||
import { DialogButton, Focusable, SteamSpinner, TextField } from 'decky-frontend-lib';
|
import {
|
||||||
import { useEffect } from 'react';
|
ControlsList,
|
||||||
import { FunctionComponent, useState } from 'react';
|
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 { FileIcon, defaultStyles } from 'react-file-icon';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { FaArrowUp, FaFolder } from 'react-icons/fa';
|
import { FaArrowUp, FaFolder } from 'react-icons/fa';
|
||||||
|
|
||||||
import Logger from '../../../logger';
|
import Logger from '../../../logger';
|
||||||
|
import DropdownMultiselect from '../DropdownMultiselect';
|
||||||
|
import FilePickerError, { FileErrorTypes } from './FilePickerError';
|
||||||
|
import TSortOption, { SortOptions } from './i18n/TSortOptions';
|
||||||
import { styleDefObj } from './iconCustomizations';
|
import { styleDefObj } from './iconCustomizations';
|
||||||
|
|
||||||
const logger = new Logger('FilePicker');
|
const logger = new Logger('FilePicker');
|
||||||
@@ -13,27 +28,89 @@ const logger = new Logger('FilePicker');
|
|||||||
export interface FilePickerProps {
|
export interface FilePickerProps {
|
||||||
startPath: string;
|
startPath: string;
|
||||||
includeFiles?: boolean;
|
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;
|
onSubmit: (val: { path: string; realpath: string }) => void;
|
||||||
closeModal?: () => void;
|
closeModal?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface File {
|
export interface File {
|
||||||
isdir: boolean;
|
isdir: boolean;
|
||||||
|
ishidden: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
realpath: string;
|
realpath: string;
|
||||||
|
size: number;
|
||||||
|
modified: number;
|
||||||
|
created: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileListing {
|
interface FileListing {
|
||||||
realpath: string;
|
realpath: string;
|
||||||
files: File[];
|
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(
|
function getList(
|
||||||
path: string,
|
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 }> {
|
): 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 = {
|
const iconStyles = {
|
||||||
@@ -44,50 +121,120 @@ const iconStyles = {
|
|||||||
const FilePicker: FunctionComponent<FilePickerProps> = ({
|
const FilePicker: FunctionComponent<FilePickerProps> = ({
|
||||||
startPath,
|
startPath,
|
||||||
includeFiles = true,
|
includeFiles = true,
|
||||||
regex,
|
filter = undefined,
|
||||||
|
includeFolders = true,
|
||||||
|
validFileExtensions = undefined,
|
||||||
|
allowAllFiles = true,
|
||||||
|
defaultHidden = false, // false by default makes sense for most users
|
||||||
|
max = 1000,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
closeModal,
|
closeModal,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
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 [path, setPath] = useState<string>(startPath);
|
||||||
const [listing, setListing] = useState<FileListing>({ files: [], realpath: path });
|
const [listing, setListing] = useState<FileListing>({ files: [], realpath: path, total: 0 });
|
||||||
const [error, setError] = useState<string | null>(null);
|
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 [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(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (error) setError(null);
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const listing = await getList(path, includeFiles);
|
const listing = await getList(
|
||||||
|
path,
|
||||||
|
includeFiles,
|
||||||
|
includeFolders,
|
||||||
|
selectedExts,
|
||||||
|
showHidden,
|
||||||
|
sort,
|
||||||
|
filter,
|
||||||
|
page,
|
||||||
|
max,
|
||||||
|
);
|
||||||
if (!listing.success) {
|
if (!listing.success) {
|
||||||
setListing({ files: [], realpath: path });
|
setListing({ files: [], realpath: path, total: 0 });
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setError(listing.result as string);
|
const theError = listing.result as string;
|
||||||
logger.error(listing.result);
|
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;
|
return;
|
||||||
|
} else {
|
||||||
|
setRawError(null);
|
||||||
|
setError(FileErrorTypes.None);
|
||||||
|
setFiles((listing.result as FileListing).files);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setListing(listing.result as FileListing);
|
setListing(listing.result as FileListing);
|
||||||
logger.log('reloaded', path, listing);
|
logger.log('reloaded', path, listing);
|
||||||
})();
|
})();
|
||||||
}, [path]);
|
}, [error, path, includeFiles, includeFolders, showHidden, sort, selectedExts, page]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="deckyFilePicker">
|
<>
|
||||||
<Focusable style={{ display: 'flex', flexDirection: 'row', paddingBottom: '10px' }}>
|
<DialogBody className="deckyFilePicker">
|
||||||
|
<DialogControlsSection>
|
||||||
|
<Focusable flow-children="right" style={{ display: 'flex', marginBottom: '1em' }}>
|
||||||
<DialogButton
|
<DialogButton
|
||||||
style={{
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
minWidth: 'unset',
|
minWidth: 'unset',
|
||||||
width: '40px',
|
width: '40px',
|
||||||
flexGrow: '0',
|
|
||||||
borderRadius: 'unset',
|
borderRadius: 'unset',
|
||||||
margin: '0',
|
margin: '0',
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newPathArr = path.split('/');
|
const newPathArr = path.split('/');
|
||||||
newPathArr.pop();
|
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('/');
|
let newPath = newPathArr.join('/');
|
||||||
if (newPath == '') newPath = '/';
|
if (newPath == '') newPath = '/';
|
||||||
setPath(newPath);
|
setPath(newPath);
|
||||||
@@ -95,7 +242,7 @@ const FilePicker: FunctionComponent<FilePickerProps> = ({
|
|||||||
>
|
>
|
||||||
<FaArrowUp />
|
<FaArrowUp />
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
<div style={{ flexGrow: '1', width: '100%' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<TextField
|
<TextField
|
||||||
value={path}
|
value={path}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -105,15 +252,37 @@ const FilePicker: FunctionComponent<FilePickerProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Focusable>
|
</Focusable>
|
||||||
<Focusable style={{ display: 'flex', flexDirection: 'column', height: '60vh', overflow: 'scroll' }}>
|
<ControlsList alignItems="center" spacing="standard">
|
||||||
{loading && <SteamSpinner style={{ height: '100%' }} />}
|
<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 &&
|
{!loading &&
|
||||||
listing.files
|
error === FileErrorTypes.None &&
|
||||||
.filter((file) => (includeFiles || file.isdir) && (!regex || regex.test(file.name)))
|
files.map((file) => {
|
||||||
.map((file) => {
|
const extension = file.realpath.split('.').pop() as string;
|
||||||
let extension = file.realpath.split('.').pop() as string;
|
|
||||||
return (
|
return (
|
||||||
<DialogButton
|
<DialogButton
|
||||||
|
key={`${file.realpath}${file.name}`}
|
||||||
style={{ borderRadius: 'unset', margin: '0', padding: '10px' }}
|
style={{ borderRadius: 'unset', margin: '0', padding: '10px' }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const fullPath = `${path}${path.endsWith('/') ? '' : '/'}${file.name}`;
|
const fullPath = `${path}${path.endsWith('/') ? '' : '/'}${file.name}`;
|
||||||
@@ -136,22 +305,30 @@ const FilePicker: FunctionComponent<FilePickerProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span
|
<Marquee>{file.name}</Marquee>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
style={{
|
style={{
|
||||||
textOverflow: 'ellipsis',
|
display: 'flex',
|
||||||
overflow: 'hidden',
|
opacity: 0.5,
|
||||||
|
fontSize: '.6em',
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
|
lineHeight: 1,
|
||||||
|
marginTop: '.5em',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{file.name}
|
{file.isdir ? t('FilePickerIndex.folder.label') : filesize(file.size, { standard: 'iec' })}
|
||||||
</span>
|
<span style={{ marginLeft: 'auto' }}>{new Date(file.modified * 1000).toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</DialogButton>
|
</DialogButton>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{error}
|
{error !== FileErrorTypes.None && <FilePickerError error={error} rawError={rawError ? rawError : ''} />}
|
||||||
</Focusable>
|
</Focusable>
|
||||||
|
</DialogControlsSection>
|
||||||
|
</DialogBody>
|
||||||
{!loading && !error && !includeFiles && (
|
{!loading && !error && !includeFiles && (
|
||||||
|
<DialogFooter>
|
||||||
<DialogButton
|
<DialogButton
|
||||||
className="Primary"
|
className="Primary"
|
||||||
style={{ marginTop: '10px', alignSelf: 'flex-end' }}
|
style={{ marginTop: '10px', alignSelf: 'flex-end' }}
|
||||||
@@ -162,8 +339,22 @@ const FilePicker: FunctionComponent<FilePickerProps> = ({
|
|||||||
>
|
>
|
||||||
{t('FilePickerIndex.folder.select')}
|
{t('FilePickerIndex.folder.select')}
|
||||||
</DialogButton>
|
</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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,26 +13,24 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { FaFileArchive, FaLink, FaReact, FaSteamSymbol, FaTerminal } from 'react-icons/fa';
|
import { FaFileArchive, FaLink, FaReact, FaSteamSymbol, FaTerminal } from 'react-icons/fa';
|
||||||
|
|
||||||
import { setShouldConnectToReactDevTools, setShowValveInternal } from '../../../../developer';
|
import { setShouldConnectToReactDevTools, setShowValveInternal } from '../../../../developer';
|
||||||
|
import Logger from '../../../../logger';
|
||||||
import { installFromURL } from '../../../../store';
|
import { installFromURL } from '../../../../store';
|
||||||
import { useSetting } from '../../../../utils/hooks/useSetting';
|
import { useSetting } from '../../../../utils/hooks/useSetting';
|
||||||
|
import { getSetting } from '../../../../utils/settings';
|
||||||
import RemoteDebuggingSettings from '../general/RemoteDebugging';
|
import RemoteDebuggingSettings from '../general/RemoteDebugging';
|
||||||
|
|
||||||
const installFromZip = () => {
|
const logger = new Logger('DeveloperIndex');
|
||||||
window.DeckyPluginLoader.openFilePicker('/home/deck', true).then((val) => {
|
|
||||||
|
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}`;
|
const url = `file://${val.path}`;
|
||||||
console.log(`Installing plugin locally from ${url}`);
|
console.log(`Installing plugin locally from ${url}`);
|
||||||
|
|
||||||
if (url.endsWith('.zip')) {
|
|
||||||
installFromURL(url);
|
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import {
|
|||||||
import { FC, lazy } from 'react';
|
import { FC, lazy } from 'react';
|
||||||
import { FaExclamationCircle, FaPlug } from 'react-icons/fa';
|
import { FaExclamationCircle, FaPlug } from 'react-icons/fa';
|
||||||
|
|
||||||
import { DeckyState, DeckyStateContextProvider, useDeckyState } from './components/DeckyState';
|
import { DeckyState, DeckyStateContextProvider, UserInfo, useDeckyState } from './components/DeckyState';
|
||||||
import LegacyPlugin from './components/LegacyPlugin';
|
import LegacyPlugin from './components/LegacyPlugin';
|
||||||
|
import { File } from './components/modals/filepicker';
|
||||||
import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches';
|
import { deinitFilepickerPatches, initFilepickerPatches } from './components/modals/filepicker/patches';
|
||||||
import MultiplePluginsInstallModal from './components/modals/MultiplePluginsInstallModal';
|
import MultiplePluginsInstallModal from './components/modals/MultiplePluginsInstallModal';
|
||||||
import PluginInstallModal from './components/modals/PluginInstallModal';
|
import PluginInstallModal from './components/modals/PluginInstallModal';
|
||||||
@@ -31,7 +32,7 @@ import TabsHook from './tabs-hook';
|
|||||||
import OldTabsHook from './tabs-hook.old';
|
import OldTabsHook from './tabs-hook.old';
|
||||||
import Toaster from './toaster';
|
import Toaster from './toaster';
|
||||||
import { VerInfo, callUpdaterMethod } from './updater';
|
import { VerInfo, callUpdaterMethod } from './updater';
|
||||||
import { getSetting } from './utils/settings';
|
import { getSetting, setSetting } from './utils/settings';
|
||||||
import TranslationHelper, { TranslationClass } from './utils/TranslationHelper';
|
import TranslationHelper, { TranslationClass } from './utils/TranslationHelper';
|
||||||
|
|
||||||
const StorePage = lazy(() => import('./components/store/Store'));
|
const StorePage = lazy(() => import('./components/store/Store'));
|
||||||
@@ -99,9 +100,17 @@ class PluginLoader extends Logger {
|
|||||||
|
|
||||||
initFilepickerPatches();
|
initFilepickerPatches();
|
||||||
|
|
||||||
|
this.getUserInfo();
|
||||||
|
|
||||||
this.updateVersion();
|
this.updateVersion();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getUserInfo() {
|
||||||
|
const userInfo = (await this.callServerMethod('get_user_info')).result as UserInfo;
|
||||||
|
setSetting('user_info.user_name', userInfo.username);
|
||||||
|
setSetting('user_info.user_home', userInfo.path);
|
||||||
|
}
|
||||||
|
|
||||||
public async updateVersion() {
|
public async updateVersion() {
|
||||||
const versionInfo = (await callUpdaterMethod('get_version')).result as VerInfo;
|
const versionInfo = (await callUpdaterMethod('get_version')).result as VerInfo;
|
||||||
this.deckyState.setVersionInfo(versionInfo);
|
this.deckyState.setVersionInfo(versionInfo);
|
||||||
@@ -268,6 +277,7 @@ class PluginLoader extends Logger {
|
|||||||
Authentication: window.deckyAuthToken,
|
Authentication: window.deckyAuthToken,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
try {
|
try {
|
||||||
let plugin_export = await eval(await res.text());
|
let plugin_export = await eval(await res.text());
|
||||||
@@ -352,7 +362,12 @@ class PluginLoader extends Logger {
|
|||||||
openFilePicker(
|
openFilePicker(
|
||||||
startPath: string,
|
startPath: string,
|
||||||
includeFiles?: boolean,
|
includeFiles?: boolean,
|
||||||
regex?: RegExp,
|
filter?: RegExp | ((file: File) => boolean),
|
||||||
|
includeFolders?: boolean,
|
||||||
|
extensions?: string[],
|
||||||
|
showHiddenFiles?: boolean,
|
||||||
|
allowAllFiles?: boolean,
|
||||||
|
max?: number,
|
||||||
): Promise<{ path: string; realpath: string }> {
|
): Promise<{ path: string; realpath: string }> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const Content = ({ closeModal }: { closeModal?: () => void }) => (
|
const Content = ({ closeModal }: { closeModal?: () => void }) => (
|
||||||
@@ -367,9 +382,14 @@ class PluginLoader extends Logger {
|
|||||||
<FilePicker
|
<FilePicker
|
||||||
startPath={startPath}
|
startPath={startPath}
|
||||||
includeFiles={includeFiles}
|
includeFiles={includeFiles}
|
||||||
regex={regex}
|
includeFolders={includeFolders}
|
||||||
|
filter={filter}
|
||||||
|
validFileExtensions={extensions}
|
||||||
|
allowAllFiles={allowAllFiles}
|
||||||
|
defaultHidden={showHiddenFiles}
|
||||||
onSubmit={resolve}
|
onSubmit={resolve}
|
||||||
closeModal={closeModal}
|
closeModal={closeModal}
|
||||||
|
max={max}
|
||||||
/>
|
/>
|
||||||
</WithSuspense>
|
</WithSuspense>
|
||||||
</ModalRoot>
|
</ModalRoot>
|
||||||
|
|||||||
Reference in New Issue
Block a user