import {Button, Header, Icon, Modal, Placeholder, Progress, Segment, Table} from "./Theme"; import { Checkbox, Form, Grid, IconGroup, Input, PlaceholderLine, TableBody, TableCell, TableHeader, TableHeaderCell, TableRow } from "semantic-ui-react"; import { APIButton, DirectorySearch, ErrorMessage, FileIcon, humanFileSize, mimetypeIconName, Toggle, useIsIgnoredDirectory } from "./Common"; import React, {useContext, useEffect, useState} from "react"; import {useDropzone} from "react-dropzone"; import {useHotkeys} from "react-hotkeys-hook"; import {deleteFile, ignoreDirectory, makeDirectory, movePaths, renamePath, unignoreDirectory} from "../api"; import _ from 'lodash'; import {SortableTable} from "./SortableTable"; import {useBrowseFiles, useElementWidth, useMediaDirectory, useUploadFile, useWROLMode} from "../hooks/customHooks"; import {FileRowTagIcon, FilesRefreshButton} from "./Files"; import {FilePreviewContext} from "./FilePreview"; import {DragSelectionContext, DragSelectionProvider, SettingsContext, ThemeContext} from "../contexts/contexts"; import {BulkTagModal} from "./BulkTagModal"; import {InlineErrorBoundary} from "./ErrorBoundary"; import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal"; function depthIndentation(path) { // Repeated spaces for every folder a path is in. const depth = (path.match(/\//g) || []).length; return '\xa0\xa0\xa0\xa0'.repeat(depth); } export function pathDirectory(path) { // Return the full path of the path's directory. // pathDirectory('/some/directory/file.txt') -> '/some/directory' return path.substring(0, path.lastIndexOf('/')); } export function pathName(path) { // Return the file's name. // pathName('/some/directory/file.txt') -> 'file.txt' // pathName('/some/directory') -> 'directory' if (path.endsWith('/')) { // Path is a directory, return its name. path = path.substring(0, path.length - 1); } return path.substring(path.lastIndexOf('/') + 1); } export function splitPathParentAndName(path) { if (path.endsWith('/')) { // Path is a directory, return its name. path = path.substring(0, path.length - 1); } const slashIndex = path.lastIndexOf('/'); return [path.substring(0, slashIndex), path.substring(slashIndex + 1, path.length)]; } export function filterBrowseFiles(browseFiles, filterStr) { // Reduce the file tree to the rows whose name matches `filterStr`, keeping the ancestor // folders of every match. A folder whose own name matches keeps its entire subtree. if (!filterStr) { return browseFiles; } const needle = filterStr.toLowerCase(); const filterList = (files) => { // `files` may be a dict keyed by name (from the API) or an array. const fileList = Array.isArray(files) ? files : Object.values(files); const matches = []; for (const file of fileList) { if (pathName(file.path).toLowerCase().includes(needle)) { matches.push(file); } else if (file.children) { const children = filterList(file.children); if (children.length > 0) { matches.push({...file, children}); } } } return matches; }; return filterList(browseFiles); } function Folder({folder, onFolderClick, sortData, onFileClick, disabled, loadingFolders}) { const {handleDragStart, handleDragMove, getDragState, shouldAllowClick, selectedPaths, onSelect} = useContext(DragSelectionContext); // Creates a single table row for a folder, or a row for itself and indented rows for its children. let {path, children, is_empty} = folder; const ignored = useIsIgnoredDirectory(path); const pathWithNoTrailingSlash = path.substring(0, path.length - 1); const name = path.substring(pathWithNoTrailingSlash.lastIndexOf('/') + 1); const isInSelectedPaths = selectedPaths.indexOf(folder['path']) >= 0; const {isDragSelecting, isDragDeselecting} = getDragState(path, isInSelectedPaths); // Preview selection: show checked if selecting, unchecked if deselecting const showAsSelected = (isInSelectedPaths && !isDragDeselecting) || isDragSelecting; // Visual highlight class const dragClass = isDragSelecting ? 'drag-selecting' : (isDragDeselecting ? 'drag-deselecting' : ''); const isLoading = loadingFolders && loadingFolders.has(path); const depth = (path.match(/\//g) || []).length; const f = handleDragStart(path, e)} onMouseEnter={() => handleDragMove(path)} > onSelect(folder['path'])}/> shouldAllowClick() && onFolderClick(path)} className='file-path' colSpan={2} disabled={is_empty}> {depthIndentation(pathWithNoTrailingSlash)} {is_empty ? : } {ignored && } {name} ; if (children) { // Folder has children, recursively display them. children = sortData(children); let childPaths = []; _.forEach(children, (p, k) => { childPaths = [...childPaths, ]; }); return {f} {childPaths} } else if (isLoading) { // Folder is loading - show skeleton rows return {f} ; } else { // Folder has no children. return f; } } function File({file, onFileClick, disabled}) { const {handleDragStart, handleDragMove, getDragState, shouldAllowClick, selectedPaths, onSelect} = useContext(DragSelectionContext); const {path, size} = file; const isInSelectedPaths = selectedPaths.indexOf(path) >= 0; const {isDragSelecting, isDragDeselecting} = getDragState(path, isInSelectedPaths); // Preview selection: show checked if selecting, unchecked if deselecting const showAsSelected = (isInSelectedPaths && !isDragDeselecting) || isDragSelecting; // Visual highlight class const dragClass = isDragSelecting ? 'drag-selecting' : (isDragDeselecting ? 'drag-deselecting' : ''); return handleDragStart(path, e)} onMouseEnter={() => handleDragMove(path)} > onSelect(path)}/> shouldAllowClick() && onFileClick(file)} className='file-path'> {depthIndentation(path)} {/*null size to make icon the correct size*/} {pathName(path)} {humanFileSize(size)} } function Path({path, onFolderClick, onFileClick, sortData, disabled, loadingFolders}) { if (path['path'].endsWith('/')) { return } else { return } } function SkeletonFileRow({depth}) { const indentation = '\xa0\xa0\xa0\xa0'.repeat(depth); return ( {indentation} ); } // Below this footer width (px) the action buttons are icon-only; at or above it they expand to show labels. // The eight labeled buttons need ~1020px to fit on a single row; this leaves a small buffer. const FOOTER_LABELS_MIN_WIDTH = 1050; export function FileBrowser() { const [selectedPaths, setSelectedPaths] = React.useState([]); const [renameOpen, setRenameOpen] = React.useState(false); const [moveOpen, setMoveOpen] = React.useState(false); const [makeDirectoryOpen, setMakeDirectoryOpen] = React.useState(false); const [ignoreDirectoryOpen, setIgnoreDirectoryOpen] = React.useState(false); const [bulkTagOpen, setBulkTagOpen] = React.useState(false); const [uploadOpen, setUploadOpen] = React.useState(false); const [pending, setPending] = React.useState(false); // Tracks which folders are currently loading their children const [loadingFolders, setLoadingFolders] = React.useState(new Set()); // Only true if only one path is selected, and it is a directory. const [singleDirectorySelected, setSingleDirectorySelected] = React.useState(false); // Only true if single directory is selected, and it is in settings['ignored_directories'] const [isDirectoryIgnored, setIsDirectoryIgnored] = React.useState(false); // Tracks folder that was opened while selected - its children will be auto-selected when they load const pendingAutoSelectRef = React.useRef(null); // Tagged file groups that would be deleted - shown in confirmation modal const [taggedFileGroups, setTaggedFileGroups] = React.useState(null); // Reduces the visible rows to those matching, and their parent folders. const [filterStr, setFilterStr] = React.useState(''); const filterInputRef = React.useRef(); // 'f' focuses the filter input, like the search inputs on other pages. useHotkeys('f', (e) => { e.preventDefault(); if (filterInputRef.current) { filterInputRef.current.focus(); } }, {enableOnFormTags: false}); const {settings, fetchSettings} = React.useContext(SettingsContext); const {inverted} = React.useContext(ThemeContext); const selectedPathsCount = selectedPaths ? selectedPaths.length : 0; const {browseFiles, openFolders, setOpenFolders, fetchFiles} = useBrowseFiles(); const wrolModeEnabled = useWROLMode(); const headers = [ {key: 'select', text: ''}, {key: 'path', text: 'Path', sortBy: i => i['path'].toLowerCase()}, {key: 'size', text: 'Size', sortBy: 'size'}, ]; const {setPreviewFile, setCallbacks} = React.useContext(FilePreviewContext); useEffect(() => { // Fetch files after tagging. setCallbacks([() => fetchFiles(openFolders)]); return () => setCallbacks(null); }, []); useEffect(() => { if (selectedPaths.length === 1 && selectedPaths[0].endsWith('/')) { setSingleDirectorySelected(true); if (!_.isEmpty(settings)) { // Used to toggle between ignore/un-ignore (eye/eye lash) icon. const directory = selectedPaths[0].slice(0, -1); setIsDirectoryIgnored(settings['ignored_directories'].indexOf(directory) >= 0); } } else { setSingleDirectorySelected(false); } }, [selectedPaths, settings]); // Auto-select immediate children when a selected folder is opened useEffect(() => { const folderToAutoSelect = pendingAutoSelectRef.current; if (folderToAutoSelect && browseFiles && typeof browseFiles === 'object') { // browseFiles is a dict: {'map/': {path: 'map/', children: {...}}, ...} // children is also a dict: {'file.txt': {path: 'map/file.txt', ...}, ...} const findFolderChildren = (files, targetFolder) => { if (!files || typeof files !== 'object') return null; // Convert dict to array of values for iteration const fileList = Array.isArray(files) ? files : Object.values(files); for (const file of fileList) { if (file.path === targetFolder && file.children) { // Return children as array of values return Array.isArray(file.children) ? file.children : Object.values(file.children); } if (file.children) { const found = findFolderChildren(file.children, targetFolder); if (found) return found; } } return null; }; const children = findFolderChildren(browseFiles, folderToAutoSelect); if (children && children.length > 0) { const childPaths = children.map(child => child.path); const newSelectedPaths = [...new Set([...selectedPaths, ...childPaths])]; setSelectedPaths(newSelectedPaths); } pendingAutoSelectRef.current = null; } }, [browseFiles]); // Clear loading states when file data updates useEffect(() => { if (loadingFolders.size > 0) { setLoadingFolders(new Set()); } }, [browseFiles]); const onIgnore = async () => { setIgnoreDirectoryOpen(false); await fetchSettings(); } const handleDeleteComplete = async () => { // Remove deleted directories from openFolders to prevent fetching non-existent directories const deletedDirs = selectedPaths.filter(p => p.endsWith('/')); if (deletedDirs.length > 0 && openFolders && openFolders.length > 0) { const newOpenFolders = openFolders.filter(folder => !deletedDirs.some(deleted => folder === deleted || folder.startsWith(deleted)) ); setOpenFolders(newOpenFolders.length > 0 ? newOpenFolders : null); } await reset(); }; const onDelete = async () => { const result = await deleteFile(selectedPaths); if (result && result.tagged) { // Show modal with tagged file groups for user to confirm. setTaggedFileGroups(result.file_groups); return; } await handleDeleteComplete(); }; const onForceDelete = async () => { setTaggedFileGroups(null); const result = await deleteFile(selectedPaths, true); if (result && result.tagged) return; // unexpected tagged response if (result && !result.ok) return; // error already toasted await handleDeleteComplete(); }; const handleMakeDirectory = async () => { setMakeDirectoryOpen(false); await reset(); } const reset = async () => { setSelectedPaths([]); setPending(true); try { await fetchFiles(); } finally { setPending(false); } } const [footerRef, footerWidth] = useElementWidth(); // Expand icon-only buttons to include labels once the footer is wide enough to fit them. const showLabels = footerWidth >= FOOTER_LABELS_MIN_WIDTH; // Semantic UI renders a labeled icon button when `content` and `labelPosition` are set, else icon-only. const labelProps = (content) => showLabels ? {content, labelPosition: 'left'} : {}; const footerClassName = `sticky-footer ${inverted}`; const footer =
setMakeDirectoryOpen(false)} parent={selectedPaths.length ? selectedPaths[0] : null} onSubmit={handleMakeDirectory} />
; const onCollapseAll = () => { if (!openFolders) return; // Deselect any paths that will no longer be visible (descendants of the open folders). const newSelectedPaths = selectedPaths.filter(p => !openFolders.some(folder => p.startsWith(folder) && p !== folder)); if (newSelectedPaths.length !== selectedPaths.length) { setSelectedPaths(newSelectedPaths); } setOpenFolders(null); } const onFolderClick = async (folder) => { let newFolders; if (openFolders.indexOf(folder) >= 0) { // Remove the folder that was clicked on, as well as its sub-folders. newFolders = openFolders.filter(i => !i.startsWith(folder)); // Unselect any children of the closed folder (they're no longer visible) const newSelectedPaths = selectedPaths.filter(p => !p.startsWith(folder) || p === folder); if (newSelectedPaths.length !== selectedPaths.length) { setSelectedPaths(newSelectedPaths); } } else { // Add the new folder to the opened folders. newFolders = [...openFolders, folder]; // Mark folder as loading so skeleton rows show setLoadingFolders(prev => new Set([...prev, folder])); // If folder is selected, mark it for auto-selection of its children if (selectedPaths.includes(folder)) { pendingAutoSelectRef.current = folder; } } console.debug(`newFolders=${newFolders}`); setOpenFolders(newFolders); } const filteredFiles = React.useMemo( () => browseFiles ? filterBrowseFiles(browseFiles, filterStr) : browseFiles, [browseFiles, filterStr], ); if (browseFiles === null) { return ; } else if (browseFiles === undefined) { return Could not fetch files } return
setFilterStr(value)} aria-label='Filter files' className='file-browser-filter' ref={filterInputRef} action={
{footer}
} function FileBrowserContent({browseFiles, headers, onFolderClick, setPreviewFile, pending, loadingFolders}) { const {isDragging} = useContext(DragSelectionContext); const containerClassName = isDragging ? 'file-browser-dragging' : ''; return (
( setPreviewFile(file)} sortData={sortData} disabled={pending} loadingFolders={loadingFolders} /> )} />
); } export function RenameModal({open, onClose, path, onSubmit, onPending}) { const [parent, name] = splitPathParentAndName(path); const [value, setValue] = React.useState(name); const disabled = value === name; const handleInputChange = (e, props) => { if (e) { e.preventDefault(); } setValue(props.value); } const handleSubmit = async (e) => { if (e) { e.preventDefault(); } try { console.log(`Renaming: ${path} to ${value}`); onPending(true); await renamePath(path, value); } finally { onClose(); onPending(false); if (onSubmit) { await onSubmit(); } } } useEffect(() => { setValue(pathName(path)); }, [path]); return Rename: {name}
New Name
{parent ? parent + '/' : null}{value}
} export function MoveModal({open, paths, onClose, onSubmit}) { const [destination, setDestination] = React.useState(''); const [pending, setPending] = React.useState(false); const handleDirectory = (value) => { setDestination(value); } const handleMove = async () => { try { setPending(true); await movePaths(destination, paths); onClose(); } catch (e) { console.error(e); setPending(false); } if (onSubmit) { await onSubmit(); } } const fileCell = i =>
{i}
; const destinationRows = paths.map(i => {fileCell(i)} {destination ? fileCell(destination + '/' + pathName(i)) : fileCell(pathName(i))} ); return Move Files/Directories

Search for a directory to move your files into:

Source Destination {destinationRows}
} export function IgnoreDirectoryModal({open, onClose, onSubmit, directory, ignored}) { const mediaDirectory = useMediaDirectory(); const [pending, setPending] = React.useState(false); const handleIgnore = async () => { try { setPending(true); if (ignored) { await unignoreDirectory(`${mediaDirectory}/${directory}`); } else { await ignoreDirectory(`${mediaDirectory}/${directory}`); } if (onSubmit) { onSubmit(); } } finally { setPending(false); } } const header = ignored ? 'Remove Ignore Directory' : 'Ignore Directory'; const content = ignored ? 'Remove ignore of the following directory? You must refresh your files after this.' : 'Ignore the following directory? The files within will not be indexed and will not show in search results.'; let submitButton = ; return {header} {content}
{mediaDirectory}/{directory}
{submitButton}
} export function MakeDirectoryModal({open, onClose, parent, onSubmit}) { const [value, setValue] = useState(''); const mediaDirectory = useMediaDirectory(); const handleInputChange = (e, props) => { if (e) { e.preventDefault(); } setValue(props.value); } const handleSubmit = async (e) => { if (e) { e.preventDefault(); } try { const path = parent ? `${parent}/${value}` : value; await makeDirectory(path); setValue(''); if (onSubmit) { await onSubmit(); } } catch (e) { console.error(e); } } return Make Directory
{mediaDirectory}/{parent}{value}
} export function FileBrowserUploadModal({open, onClose, destination, onComplete}) { const mediaDirectory = useMediaDirectory(); const {t} = React.useContext(ThemeContext); const { setFiles, progresses, setDestination, doClear, tagsSelector, overwrite, setOverwrite, overallProgress, inProgress, } = useUploadFile(); const onDrop = React.useCallback(async (acceptedFiles) => { if (acceptedFiles && acceptedFiles.length > 0) { setFiles(acceptedFiles); } }, [setFiles]); const {getRootProps, getInputProps} = useDropzone({onDrop, disabled: inProgress}); // Set destination when modal opens or destination changes useEffect(() => { if (open) { setDestination(destination); } }, [open, destination, setDestination]); // Clear upload state when destination is cleared useEffect(() => { if (!destination && open) { doClear(); } }, [destination, open, doClear]); const handleClose = () => { if (!inProgress) { doClear(); onClose(); if (onComplete) { onComplete(); } } }; let progressBars; if (!_.isEmpty(progresses)) { progressBars = Object.entries(progresses).map(([name, value]) => { const {percent, status, type} = value; let color = 'grey'; let statusString; if (status === 'complete') { color = 'green'; statusString = 'Complete:'; } else if (status === 'pending') { statusString = 'Pending:'; } else if (status === 'failed') { color = 'red'; statusString = 'Failed:'; } else if (status === 'conflicting') { color = 'orange'; statusString = 'Already Exists:'; } let indicating = status === 'pending'; return

{statusString} {name}

}) } const displayPath = destination ? `${mediaDirectory}/${destination}` : mediaDirectory; return Upload to: {displayPath}
{tagsSelector}
setOverwrite(!overwrite)} disabled={inProgress} />
Click here, or drop files here to upload
{progresses && Object.keys(progresses).length > 1 && } {progressBars}
}