mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 16:34:02 +01:00
Add filtering to folder select (#4277)
This commit is contained in:
parent
61f4d8bd12
commit
a0f33e3dab
10 changed files with 120 additions and 85 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
};
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue