Add filtering to folder select (#4277)

This commit is contained in:
InfiniteStash 2023-11-20 03:00:28 +01:00 committed by GitHub
parent 61f4d8bd12
commit a0f33e3dab
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 120 additions and 85 deletions

View file

@ -4,6 +4,7 @@ import (
"io/fs" "io/fs"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"golang.org/x/text/collate" "golang.org/x/text/collate"
) )
@ -25,13 +26,27 @@ func (s dirLister) Bytes(i int) []byte {
// listDir will return the contents of a given directory path as a string slice // listDir will return the contents of a given directory path as a string slice
func listDir(col *collate.Collator, path string) ([]string, error) { func listDir(col *collate.Collator, path string) ([]string, error) {
var dirPaths []string var dirPaths []string
dirPath := path
files, err := os.ReadDir(path) files, err := os.ReadDir(path)
if err != nil { if err != nil {
path = filepath.Dir(path) dirPath = filepath.Dir(path)
files, err = os.ReadDir(path) dirFiles, err := os.ReadDir(dirPath)
if err != nil { if err != nil {
return dirPaths, err return dirPaths, err
} }
// Filter dir contents by last path fragment if the dir isn't an exact match
base := strings.ToLower(filepath.Base(path))
if base != "." && base != string(filepath.Separator) {
for _, file := range dirFiles {
if strings.HasPrefix(strings.ToLower(file.Name()), base) {
files = append(files, file)
}
}
} else {
files = dirFiles
}
} }
if col != nil { if col != nil {
@ -42,7 +57,7 @@ func listDir(col *collate.Collator, path string) ([]string, error) {
if !file.IsDir() { if !file.IsDir() {
continue continue
} }
dirPaths = append(dirPaths, filepath.Join(path, file.Name())) dirPaths = append(dirPaths, filepath.Join(dirPath, file.Name()))
} }
return dirPaths, nil return dirPaths, nil
} }

View file

@ -50,7 +50,7 @@ func getDir(path string) string {
} }
func getParent(path string) *string { func getParent(path string) *string {
isRoot := path[len(path)-1:] == "/" isRoot := path == "/"
if isRoot { if isRoot {
return nil return nil
} else { } else {

View file

@ -37,9 +37,9 @@ export const PathFilter: React.FC<IInputFilterProps> = ({
) : ( ) : (
<FolderSelect <FolderSelect
currentDirectory={criterion.value ? criterion.value.toString() : ""} currentDirectory={criterion.value ? criterion.value.toString() : ""}
setCurrentDirectory={(v) => onValueChanged(v)} onChangeDirectory={onValueChanged}
collapsible collapsible
quoteSpaced quotePath
hideError hideError
defaultDirectories={libraryPaths} defaultDirectories={libraryPaths}
/> />

View file

@ -106,7 +106,7 @@ const CleanDialog: React.FC<ICleanDialog> = ({
{pathSelection ? ( {pathSelection ? (
<FolderSelect <FolderSelect
currentDirectory={currentDirectory} currentDirectory={currentDirectory}
setCurrentDirectory={(v) => setCurrentDirectory(v)} onChangeDirectory={setCurrentDirectory}
defaultDirectories={libraryPaths} defaultDirectories={libraryPaths}
appendButton={ appendButton={
<Button <Button

View file

@ -80,7 +80,7 @@ export const DirectorySelectionDialog: React.FC<
<FolderSelect <FolderSelect
currentDirectory={currentDirectory} currentDirectory={currentDirectory}
setCurrentDirectory={(v) => setCurrentDirectory(v)} onChangeDirectory={setCurrentDirectory}
defaultDirectories={libraryPaths} defaultDirectories={libraryPaths}
appendButton={ appendButton={
<Button <Button

View file

@ -1,89 +1,76 @@
import React, { useEffect, useState } from "react"; import React, { useState } from "react";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import { Button, InputGroup, Form, Collapse } from "react-bootstrap"; import { Button, InputGroup, Form, Collapse } from "react-bootstrap";
import { Icon } from "../Icon"; import { Icon } from "../Icon";
import { LoadingIndicator } from "../LoadingIndicator"; import { LoadingIndicator } from "../LoadingIndicator";
import { useDirectory } from "src/core/StashService";
import { faEllipsis, faTimes } from "@fortawesome/free-solid-svg-icons"; import { faEllipsis, faTimes } from "@fortawesome/free-solid-svg-icons";
import { useDebounce } from "src/hooks/debounce"; import { useDebounce } from "src/hooks/debounce";
import TextUtils from "src/utils/text";
import { useDirectoryPaths } from "./useDirectoryPaths";
interface IProps { interface IProps {
currentDirectory: string; currentDirectory: string;
setCurrentDirectory: (value: string) => void; onChangeDirectory: (value: string) => void;
defaultDirectories?: string[]; defaultDirectories?: string[];
appendButton?: JSX.Element; appendButton?: JSX.Element;
collapsible?: boolean; collapsible?: boolean;
quoteSpaced?: boolean; quotePath?: boolean;
hideError?: boolean; hideError?: boolean;
} }
export const FolderSelect: React.FC<IProps> = ({ export const FolderSelect: React.FC<IProps> = ({
currentDirectory, currentDirectory,
setCurrentDirectory, onChangeDirectory,
defaultDirectories, defaultDirectories = [],
appendButton, appendButton,
collapsible = false, collapsible = false,
quoteSpaced = false, quotePath = false,
hideError = false, hideError = false,
}) => { }) => {
const [showBrowser, setShowBrowser] = React.useState(false); const intl = useIntl();
const [directory, setDirectory] = useState(currentDirectory); const [showBrowser, setShowBrowser] = useState(false);
const [path, setPath] = useState(currentDirectory);
const isQuoted = const normalizedPath = quotePath ? TextUtils.stripQuotes(path) : path;
quoteSpaced && directory.startsWith('"') && directory.endsWith('"'); const { directories, parent, error, loading } = useDirectoryPaths(
const { data, error, loading } = useDirectory( normalizedPath,
isQuoted ? directory.slice(1, -1) : directory hideError
); );
const intl = useIntl(); const selectableDirectories =
(currentDirectory ? directories : defaultDirectories) ?? defaultDirectories;
const defaultDirectoriesOrEmpty = defaultDirectories ?? []; const debouncedSetDirectory = useDebounce(setPath, 250);
const selectableDirectories: string[] = currentDirectory
? data?.directory.directories ??
(error && hideError ? [] : defaultDirectoriesOrEmpty)
: defaultDirectoriesOrEmpty;
const debouncedSetDirectory = useDebounce(setDirectory, 250);
useEffect(() => {
if (currentDirectory !== directory) {
debouncedSetDirectory(currentDirectory);
}
}, [currentDirectory, directory, debouncedSetDirectory]);
function setInstant(value: string) { function setInstant(value: string) {
if (quoteSpaced && value.includes(" ")) { const normalizedValue =
value = `"${value}"`; quotePath && value.includes(" ") ? TextUtils.addQuotes(value) : value;
} onChangeDirectory(normalizedValue);
setPath(normalizedValue);
setCurrentDirectory(value);
setDirectory(value);
} }
function setDebounced(value: string) { function setDebounced(value: string) {
setCurrentDirectory(value); onChangeDirectory(value);
debouncedSetDirectory(value); debouncedSetDirectory(value);
} }
function goUp() { function goUp() {
if (defaultDirectories?.includes(currentDirectory)) { if (defaultDirectories?.includes(currentDirectory)) {
setInstant(""); setInstant("");
} else if (data?.directory.parent) { } else if (parent) {
setInstant(data.directory.parent); setInstant(parent);
} }
} }
const topDirectory = const topDirectory = currentDirectory && parent && (
currentDirectory && data?.directory?.parent ? (
<li className="folder-list-parent folder-list-item"> <li className="folder-list-parent folder-list-item">
<Button variant="link" onClick={() => goUp()}> <Button variant="link" onClick={() => goUp()} disabled={loading}>
<span> <span>
<FormattedMessage id="setup.folder.up_dir" /> <FormattedMessage id="setup.folder.up_dir" />
</span> </span>
</Button> </Button>
</li> </li>
) : null; );
return ( return (
<> <>
@ -91,16 +78,16 @@ export const FolderSelect: React.FC<IProps> = ({
<Form.Control <Form.Control
className="btn-secondary" className="btn-secondary"
placeholder={intl.formatMessage({ id: "setup.folder.file_path" })} placeholder={intl.formatMessage({ id: "setup.folder.file_path" })}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => { onChange={(e) => {
setDebounced(e.currentTarget.value); setDebounced(e.currentTarget.value);
}} }}
value={currentDirectory} value={currentDirectory}
spellCheck={false} spellCheck={false}
/> />
{appendButton ? (
<InputGroup.Append>{appendButton}</InputGroup.Append> {appendButton && <InputGroup.Append>{appendButton}</InputGroup.Append>}
) : undefined}
{collapsible ? ( {collapsible && (
<InputGroup.Append> <InputGroup.Append>
<Button <Button
variant="secondary" variant="secondary"
@ -109,32 +96,37 @@ export const FolderSelect: React.FC<IProps> = ({
<Icon icon={faEllipsis} /> <Icon icon={faEllipsis} />
</Button> </Button>
</InputGroup.Append> </InputGroup.Append>
) : undefined} )}
{!data || !data.directory || loading ? (
{(loading || error) && (
<InputGroup.Append className="align-self-center"> <InputGroup.Append className="align-self-center">
{loading ? ( {loading ? (
<LoadingIndicator inline small message="" /> <LoadingIndicator inline small message="" />
) : !hideError ? ( ) : (
<Icon icon={faTimes} color="red" className="ml-3" /> !hideError && <Icon icon={faTimes} color="red" className="ml-3" />
) : undefined} )}
</InputGroup.Append> </InputGroup.Append>
) : undefined} )}
</InputGroup> </InputGroup>
{!hideError && error !== undefined && ( {!hideError && error !== undefined && (
<h5 className="mt-4 text-break">Error: {error.message}</h5> <h5 className="mt-4 text-break">Error: {error.message}</h5>
)} )}
<Collapse in={!collapsible || showBrowser}> <Collapse in={!collapsible || showBrowser}>
<ul className="folder-list"> <ul className="folder-list">
{topDirectory} {topDirectory}
{selectableDirectories.map((path) => { {selectableDirectories.map((dir) => (
return ( <li key={dir} className="folder-list-item">
<li key={path} className="folder-list-item"> <Button
<Button variant="link" onClick={() => setInstant(path)}> variant="link"
<span>{path}</span> onClick={() => setInstant(dir)}
disabled={loading}
>
<span>{dir}</span>
</Button> </Button>
</li> </li>
); ))}
})}
</ul> </ul>
</Collapse> </Collapse>
</> </>

View file

@ -23,7 +23,7 @@ export const FolderSelectDialog: React.FC<IProps> = ({
<div className="dialog-content"> <div className="dialog-content">
<FolderSelect <FolderSelect
currentDirectory={currentDirectory} currentDirectory={currentDirectory}
setCurrentDirectory={(v) => setCurrentDirectory(v)} onChangeDirectory={setCurrentDirectory}
/> />
</div> </div>
</Modal.Body> </Modal.Body>

View file

@ -0,0 +1,16 @@
import { useRef } from "react";
import { useDirectory } from "src/core/StashService";
export const useDirectoryPaths = (path: string, hideError: boolean) => {
const { data, loading, error } = useDirectory(path);
const prevData = useRef<typeof data | undefined>(undefined);
if (!loading) prevData.current = data;
const currentData = loading ? prevData.current : data;
const directories =
error && hideError ? [] : currentData?.directory.directories;
const parent = currentData?.directory.parent;
return { directories, parent, loading, error };
};

View file

@ -80,10 +80,9 @@
} }
} }
.folder-list { // z-index gets set on button groups for some reason
margin-top: 0.5rem 0 0 0; .multi-set .btn-group > button.btn {
max-height: 30vw; z-index: auto;
overflow-x: auto;
} }
.folder-item { .folder-item {
@ -92,14 +91,12 @@
} }
} }
// z-index gets set on button groups for some reason
.multi-set .btn-group > button.btn {
z-index: auto;
}
.folder-list { .folder-list {
list-style-type: none; list-style-type: none;
margin: 0; margin: 0;
max-height: 30vw;
overflow-x: auto;
padding-bottom: 0.5rem;
padding-top: 1rem; padding-top: 1rem;
&-item { &-item {

View file

@ -455,6 +455,19 @@ const abbreviateCounter = (counter: number = 0) => {
}; };
}; };
/*
* Trims quotes if the text has leading/trailing quotes
*/
const stripQuotes = (text: string) => {
if (text.startsWith('"') && text.endsWith('"')) return text.slice(1, -1);
return text;
};
/*
* Wraps string in quotes
*/
const addQuotes = (text: string) => `"${text}"`;
const TextUtils = { const TextUtils = {
fileSize, fileSize,
formatFileSizeUnit, formatFileSizeUnit,
@ -478,6 +491,8 @@ const TextUtils = {
formatDateTime, formatDateTime,
secondsAsTimeString, secondsAsTimeString,
abbreviateCounter, abbreviateCounter,
stripQuotes,
addQuotes,
}; };
export default TextUtils; export default TextUtils;