diff options
Diffstat (limited to 'frontend/src/components/modals/filepicker')
5 files changed, 398 insertions, 0 deletions
diff --git a/frontend/src/components/modals/filepicker/iconCustomizations.ts b/frontend/src/components/modals/filepicker/iconCustomizations.ts new file mode 100644 index 00000000..e09c9e67 --- /dev/null +++ b/frontend/src/components/modals/filepicker/iconCustomizations.ts @@ -0,0 +1,170 @@ +// https://codesandbox.io/s/react-file-icon-colored-tmwut?file=/src/App.js +import { FileIconProps } from 'react-file-icon'; + +type T_FileExtList = string[]; + +const styleDef: [FileIconProps, T_FileExtList][] = []; + +// video //////////////////////////////////// +const videoStyle = { + color: '#f00f0f', +}; +const videoExtList = [ + 'avi', + '3g2', + '3gp', + 'aep', + 'asf', + 'flv', + 'm4v', + 'mkv', + 'mov', + 'mp4', + 'mpeg', + 'mpg', + 'ogv', + 'pr', + 'swfw', + 'webm', + 'wmv', + 'swf', + 'rm', +]; + +styleDef.push([videoStyle, videoExtList]); + +// image //////////////////////////////////// +const imageStyle = { + color: '#d18f00', +}; + +const imageExtList = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'tif', 'tiff']; + +styleDef.push([imageStyle, imageExtList]); + +// zip //////////////////////////////////// +const zipStyle = { + color: '#f7b500', + labelTextColor: '#000', + // glyphColor: "#de9400" +}; + +const zipExtList = ['zip', 'zipx', '7zip', 'tar', 'sitx', 'gz', 'rar']; + +styleDef.push([zipStyle, zipExtList]); + +// audio //////////////////////////////////// +const audioStyle = { + color: '#f00f0f', +}; + +const audioExtList = ['aac', 'aif', 'aiff', 'flac', 'm4a', 'mid', 'mp3', 'ogg', 'wav']; + +styleDef.push([audioStyle, audioExtList]); + +// text //////////////////////////////////// +const textStyle = { + color: '#ffffff', + glyphColor: '#787878', +}; + +const textExtList = ['cue', 'odt', 'md', 'rtf', 'txt', 'tex', 'wpd', 'wps', 'xlr', 'fodt']; + +styleDef.push([textStyle, textExtList]); + +// system //////////////////////////////////// +const systemStyle = { + color: '#111', +}; + +const systemExtList = ['exe', 'ini', 'dll', 'plist', 'sys']; + +styleDef.push([systemStyle, systemExtList]); + +// srcCode //////////////////////////////////// +const srcCodeStyle = { + glyphColor: '#787878', + color: '#ffffff', +}; + +const srcCodeExtList = [ + 'asp', + 'aspx', + 'c', + 'cpp', + 'cs', + 'css', + 'scss', + 'py', + 'json', + 'htm', + 'html', + 'java', + 'yml', + 'php', + 'js', + 'ts', + 'rb', + 'jsx', + 'tsx', +]; + +styleDef.push([srcCodeStyle, srcCodeExtList]); + +// vector //////////////////////////////////// +const vectorStyle = { + color: '#ffe600', +}; + +const vectorExtList = ['dwg', 'dxf', 'ps', 'svg', 'eps']; + +styleDef.push([vectorStyle, vectorExtList]); + +// font //////////////////////////////////// +const fontStyle = { + color: '#555', +}; + +const fontExtList = ['fnt', 'ttf', 'otf', 'fon', 'eot', 'woff']; + +styleDef.push([fontStyle, fontExtList]); + +// objectModel //////////////////////////////////// +const objectModelStyle = { + color: '#bf6a02', + glyphColor: '#bf6a02', +}; + +const objectModelExtList = ['3dm', '3ds', 'max', 'obj', 'pkg']; + +styleDef.push([objectModelStyle, objectModelExtList]); + +// sheet //////////////////////////////////// +const sheetStyle = { + color: '#2a6e00', +}; + +const sheetExtList = ['csv', 'fods', 'ods', 'xlr']; + +styleDef.push([sheetStyle, sheetExtList]); + +// const defaultStyle: Record<string, FileIconProps> = { +// pdf: { +// glyphColor: "white", +// color: "#D93831" +// } +// }; + +////////////////////////////////////////////////// + +function createStyleObj(extList: T_FileExtList, styleObj: Partial<FileIconProps>) { + return Object.fromEntries( + extList.map((ext) => { + return [ext, { ...styleObj, glyphColor: 'white' }]; + }), + ); +} + +export const styleDefObj = styleDef.reduce((acc, [fileStyle, fileExtList]) => { + return { ...acc, ...createStyleObj(fileExtList, fileStyle) }; +}); diff --git a/frontend/src/components/modals/filepicker/index.tsx b/frontend/src/components/modals/filepicker/index.tsx new file mode 100644 index 00000000..dcf179a3 --- /dev/null +++ b/frontend/src/components/modals/filepicker/index.tsx @@ -0,0 +1,160 @@ +import { DialogButton, Focusable, SteamSpinner, TextField } from 'decky-frontend-lib'; +import { useEffect } from 'react'; +import { FunctionComponent, useState } from 'react'; +import { FileIcon, defaultStyles } from 'react-file-icon'; +import { FaArrowUp, FaFolder } from 'react-icons/fa'; + +import Logger from '../../../logger'; +import { styleDefObj } from './iconCustomizations'; + +const logger = new Logger('FilePicker'); + +export interface FilePickerProps { + startPath: string; + includeFiles?: boolean; + regex?: RegExp; + onSubmit: (val: { path: string; realpath: string }) => void; + closeModal?: () => void; +} + +interface File { + isdir: boolean; + name: string; + realpath: string; +} + +interface FileListing { + realpath: string; + files: File[]; +} + +function getList( + path: string, + includeFiles: boolean = true, +): Promise<{ result: FileListing | string; success: boolean }> { + return window.DeckyPluginLoader.callServerMethod('filepicker_ls', { path, include_files: includeFiles }); +} + +const iconStyles = { + paddingRight: '10px', + width: '1em', +}; + +const FilePicker: FunctionComponent<FilePickerProps> = ({ + startPath, + includeFiles = true, + regex, + onSubmit, + closeModal, +}) => { + if (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 [loading, setLoading] = useState<boolean>(true); + + useEffect(() => { + (async () => { + if (error) setError(null); + setLoading(true); + const listing = await getList(path, includeFiles); + if (!listing.success) { + setListing({ files: [], realpath: path }); + setLoading(false); + setError(listing.result as string); + logger.error(listing.result); + return; + } + setLoading(false); + setListing(listing.result as FileListing); + logger.log('reloaded', path, listing); + })(); + }, [path]); + + 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> + )} + {file.name} + </div> + </DialogButton> + ); + })} + {error} + </Focusable> + {!loading && !error && !includeFiles && ( + <DialogButton + className="Primary" + style={{ marginTop: '10px', alignSelf: 'flex-end' }} + onClick={() => { + onSubmit({ path, realpath: listing.realpath }); + closeModal?.(); + }} + > + Use this folder + </DialogButton> + )} + </div> + ); +}; + +export default FilePicker; diff --git a/frontend/src/components/modals/filepicker/patches/README.md b/frontend/src/components/modals/filepicker/patches/README.md new file mode 100644 index 00000000..154914c5 --- /dev/null +++ b/frontend/src/components/modals/filepicker/patches/README.md @@ -0,0 +1 @@ +This directory contains patches that replace Valve's broken file picker with ours. diff --git a/frontend/src/components/modals/filepicker/patches/index.ts b/frontend/src/components/modals/filepicker/patches/index.ts new file mode 100644 index 00000000..310bfbf8 --- /dev/null +++ b/frontend/src/components/modals/filepicker/patches/index.ts @@ -0,0 +1,10 @@ +import library from './library'; +let patches: Function[] = []; + +export function deinitFilepickerPatches() { + patches.forEach((unpatch) => unpatch()); +} + +export async function initFilepickerPatches() { + patches.push(await library()); +} diff --git a/frontend/src/components/modals/filepicker/patches/library.ts b/frontend/src/components/modals/filepicker/patches/library.ts new file mode 100644 index 00000000..c9c7d53c --- /dev/null +++ b/frontend/src/components/modals/filepicker/patches/library.ts @@ -0,0 +1,57 @@ +import { Patch, findModuleChild, replacePatch } from 'decky-frontend-lib'; + +declare global { + interface Window { + SteamClient: any; + appDetailsStore: any; + } +} + +let patch: Patch; + +function rePatch() { + // If you patch anything on SteamClient within the first few seconds of the client having loaded it will get redefined for some reason, so repatch any of these changes that occur within the first 20s of the last patch + patch = replacePatch(window.SteamClient.Apps, 'PromptToChangeShortcut', async ([appid]: number[]) => { + try { + const details = window.appDetailsStore.GetAppDetails(appid); + console.log(details); + // strShortcutStartDir + const file = await window.DeckyPluginLoader.openFilePicker(details.strShortcutStartDir.replaceAll('"', '')); + console.log('user selected', file); + window.SteamClient.Apps.SetShortcutExe(appid, JSON.stringify(file.path)); + const pathArr = file.path.split('/'); + pathArr.pop(); + const folder = pathArr.join('/'); + window.SteamClient.Apps.SetShortcutStartDir(appid, JSON.stringify(folder)); + } catch (e) { + console.error(e); + } + }); +} + +// TODO type and add to frontend-lib +const History = findModuleChild((m) => { + if (typeof m !== 'object') return undefined; + for (let prop in m) { + if (m[prop]?.m_history) return m[prop].m_history; + } +}); + +export default async function libraryPatch() { + try { + rePatch(); + const unlisten = History.listen(() => { + if (window.SteamClient.Apps.PromptToChangeShortcut !== patch.patchedFunction) { + rePatch(); + } + }); + + return () => { + patch.unpatch(); + unlisten(); + }; + } catch (e) { + console.error('Error patching library file picker', e); + } + return () => {}; +} |
