From 57f4555350c669e4cb098b48691975be79838468 Mon Sep 17 00:00:00 2001
From: Marco Rodolfi <marco.rodolfi@tuta.io>
Date: Mon, 19 Jun 2023 15:23:27 +0200
Subject: [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
---
 .../src/components/modals/filepicker/index.tsx     | 393 +++++++++++++++------
 1 file changed, 292 insertions(+), 101 deletions(-)

(limited to 'frontend/src/components/modals/filepicker/index.tsx')

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>
+    </>
   );
 };
 
-- 
cgit v1.2.3

