summaryrefslogtreecommitdiff
path: root/frontend/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'frontend/src/components')
-rw-r--r--frontend/src/components/WithSuspense.tsx32
-rw-r--r--frontend/src/components/modals/filepicker/iconCustomizations.ts170
-rw-r--r--frontend/src/components/modals/filepicker/index.tsx159
-rw-r--r--frontend/src/components/modals/filepicker/patches/README.md1
-rw-r--r--frontend/src/components/modals/filepicker/patches/index.ts10
-rw-r--r--frontend/src/components/modals/filepicker/patches/library.ts32
6 files changed, 404 insertions, 0 deletions
diff --git a/frontend/src/components/WithSuspense.tsx b/frontend/src/components/WithSuspense.tsx
new file mode 100644
index 00000000..7460aa3d
--- /dev/null
+++ b/frontend/src/components/WithSuspense.tsx
@@ -0,0 +1,32 @@
+import { SteamSpinner } from 'decky-frontend-lib';
+import { FunctionComponent, ReactElement, ReactNode, Suspense } from 'react';
+
+interface WithSuspenseProps {
+ children: ReactNode;
+}
+
+// Nice little wrapper around Suspense so we don't have to duplicate the styles and code for the loading spinner
+const WithSuspense: FunctionComponent<WithSuspenseProps> = (props) => {
+ const propsCopy = { ...props };
+ delete propsCopy.children;
+ (props.children as ReactElement)?.props && Object.assign((props.children as ReactElement).props, propsCopy); // There is probably a better way to do this but valve does it this way so ¯\_(ツ)_/¯
+ return (
+ <Suspense
+ fallback={
+ <div
+ style={{
+ marginTop: '40px',
+ height: 'calc( 100% - 40px )',
+ overflowY: 'scroll',
+ }}
+ >
+ <SteamSpinner />
+ </div>
+ }
+ >
+ {props.children}
+ </Suspense>
+ );
+};
+
+export default WithSuspense;
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..0847bd14
--- /dev/null
+++ b/frontend/src/components/modals/filepicker/index.tsx
@@ -0,0 +1,159 @@
+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();
+ const newPath = newPathArr.join('/');
+ 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}/${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..8792900d
--- /dev/null
+++ b/frontend/src/components/modals/filepicker/patches/library.ts
@@ -0,0 +1,32 @@
+import { replacePatch, sleep } from 'decky-frontend-lib';
+
+declare global {
+ interface Window {
+ SteamClient: any;
+ appDetailsStore: any;
+ }
+}
+
+export default async function libraryPatch() {
+ await sleep(10000); // If you patch anything on SteamClient within the first few seconds of the client having loaded it will get redefined for some reason, so wait 10s
+ const 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);
+ }
+ });
+
+ return () => {
+ patch.unpatch();
+ };
+}