diff --git a/package-lock.json b/package-lock.json index 0c9a7ff..12cb02f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "react-dom": "^18.3.1", "react-router-dom": "^6.28.1", "use-broadcast-ts": "^2.0.1", + "use-debounce": "^10.0.5", "zustand": "^5.0.7" }, "devDependencies": { @@ -7492,6 +7493,18 @@ } } }, + "node_modules/use-debounce": { + "version": "10.0.5", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.0.5.tgz", + "integrity": "sha512-Q76E3lnIV+4YT9AHcrHEHYmAd9LKwUAbPXDm7FlqVGDHiSOhX3RDjT8dm0AxbJup6WgOb1YEcKyCr11kBJR5KQ==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-isomorphic-layout-effect": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.1.tgz", diff --git a/package.json b/package.json index 64999bd..33af6b9 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "react-dom": "^18.3.1", "react-router-dom": "^6.28.1", "use-broadcast-ts": "^2.0.1", + "use-debounce": "^10.0.5", "zustand": "^5.0.7" }, "devDependencies": { diff --git a/src/components/PathFinder.tsx b/src/components/PathFinder.tsx index 52619d3..910e9ec 100644 --- a/src/components/PathFinder.tsx +++ b/src/components/PathFinder.tsx @@ -4,14 +4,15 @@ import { cn } from '@heroui/react' import { open } from '@tauri-apps/plugin-dialog' import { readDir } from '@tauri-apps/plugin-fs' import { platform } from '@tauri-apps/plugin-os' -import { ArrowDownUp, FolderOpen } from 'lucide-react' -import { useCallback, useEffect, useState } from 'react' +import { ArrowDownUp, FolderOpen, XIcon } from 'lucide-react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useDebounce } from 'use-debounce' import { isRemotePath } from '../../lib/fs' import { listPath } from '../../lib/rclone/api' import { useStore } from '../../lib/store' import { lockWindows, unlockWindows } from '../../lib/window' -export default function PathFinder({ +export function PathFinder({ sourcePath = '', setSourcePath, destPath = '', @@ -19,16 +20,16 @@ export default function PathFinder({ switchable = true, sourceOptions = { label: 'Source', - folderPicker: true, - placeholder: 'Enter a remote:/path or local path', - remoteSuggestions: true, + showPicker: true, + placeholder: 'Enter a remote:/path or local path, or tap to select a folder', + showSuggestions: true, clearable: true, }, destOptions = { label: 'Destination', - folderPicker: true, + showPicker: true, placeholder: 'Enter a remote:/path or local path', - remoteSuggestions: true, + showSuggestions: true, clearable: true, }, }: { @@ -40,84 +41,442 @@ export default function PathFinder({ sourceOptions?: { label: string placeholder: string - folderPicker: boolean - remoteSuggestions: boolean + showPicker: boolean + showSuggestions: boolean clearable: boolean } destOptions?: { label: string placeholder: string - folderPicker: boolean - remoteSuggestions: boolean + showPicker: boolean + showSuggestions: boolean clearable: boolean } }) { - const remotes = useStore((state) => state.remotes) - - const [suggestions, setSuggestions] = useState< - Record< - 'source' | 'dest', - { - IsDir: boolean - Name: string - Path: string - }[] - > - >({ - source: [], - dest: [], - }) - - const [isLoading, setIsLoading] = useState<{ source: boolean; dest: boolean }>({ - source: false, - dest: false, - }) - const [error, setError] = useState<{ - source: string | null - dest: string | null - }>({ - source: null, - dest: null, - }) - const handleSwap = () => { const temp = sourcePath setSourcePath(destPath) setDestPath(temp) } - const handleBrowse = useCallback( - async (field: 'source' | 'dest') => { - try { - await lockWindows() - const selected = await open({ - directory: true, - multiple: false, - defaultPath: field === 'source' ? sourcePath : destPath, - }) - await unlockWindows() - if (selected) { - if (field === 'source') { - setSourcePath(selected as string) - } else { - setDestPath(selected as string) - } - } - } catch (err) { - console.error('Failed to open folder picker:', err) - setError((prev) => ({ - ...prev, - [field]: 'Failed to open folder picker', - })) - } - }, - [destPath, sourcePath, setDestPath, setSourcePath] + return ( +
+ + + {switchable && ( +
+ +
+ )} + + +
) +} + +export function MultiPathFinder({ + sourcePaths = [], + setSourcePaths, + destPath = '', + setDestPath, + switchable = true, + sourceOptions = { + label: 'Source', + showPicker: true, + placeholder: 'Enter a remote:/path or local path, or tap to select files', + showSuggestions: true, + clearable: true, + }, + destOptions = { + label: 'Destination', + showPicker: true, + placeholder: 'Enter a remote:/path or local path', + showSuggestions: true, + clearable: true, + }, +}: { + sourcePaths?: string[] + setSourcePaths: (paths: string[] | undefined) => void + destPath?: string + setDestPath: (path: string | undefined) => void + switchable?: boolean + sourceOptions?: { + label: string + placeholder: string + showPicker: boolean + showSuggestions: boolean + clearable: boolean + } + destOptions?: { + label: string + placeholder: string + showPicker: boolean + showSuggestions: boolean + clearable: boolean + } +}) { + const handleSwap = () => { + if (sourcePaths.length !== 1) { + return + } + const temp = sourcePaths[0] + setSourcePaths([destPath]) + setDestPath(temp) + } + + const isSwapDisabled = useMemo(() => { + return sourcePaths.length !== 1 || !destPath + }, [sourcePaths, destPath]) + + const swapDisabledReason = useMemo(() => { + if (sourcePaths.length === 0) { + return 'No source selected' + } + if (sourcePaths.length >= 2) { + return 'Cannot swap when multiple sources are selected' + } + if (!destPath) { + return 'No destination selected' + } + return 'Swap sources' + }, [sourcePaths, destPath]) + + return ( +
+ + + {switchable && ( +
+ +
+ +
+
+
+ )} + + +
+ ) +} + +function PathField({ + path, + setPath, + label, + placeholder = 'Enter a remote:/path or local path, or tap to select files', + showSuggestions = true, + clearable = true, + pickerEnabled = true, +}: { + path: string + setPath: (path: string) => void + label: string + placeholder?: string + showSuggestions?: boolean + clearable?: boolean + pickerEnabled?: boolean +}) { + const remotes = useStore((state) => state.remotes) + + const [debouncedPath] = useDebounce(path, 200) + + const fieldRef = useRef(null) + + const [suggestions, setSuggestions] = useState< + { + IsDir: boolean + Name: string + Path: string + }[] + >([]) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const visibleSuggestions = useMemo(() => { + if (!showSuggestions) { + return [] + } + return suggestions + }, [suggestions, showSuggestions]) const fetchSuggestions = useCallback( - async (path: string, field: 'source' | 'dest') => { - setIsLoading((prev) => ({ ...prev, [field]: true })) - setError((prev) => ({ ...prev, [field]: null })) + async (searchPath: string) => { + setIsLoading(true) + setError(null) + + // console.log('fetching suggestions for', path, field) + + const slashSymbol = platform() === 'windows' ? '\\' : '/' + + try { + // If path is empty, show list of remotes + if (!searchPath) { + const remoteItems = remotes.map((remote) => ({ + IsDir: true, + Name: remote + ':/', + Path: remote + ':/', + })) + setSuggestions(remoteItems) + return + } + + // Fetch suggestions for local paths + if (!isRemotePath(searchPath)) { + let localEntries: Awaited> = [] + let cleanedPath = searchPath + + try { + localEntries = await readDir(cleanedPath) + } catch (err) { + // most likely due to the path being a file + console.error('Failed to fetch local suggestions:', err) + try { + // we also retry in case the last part of the path is wrong + cleanedPath = searchPath + .split(slashSymbol) + .slice(0, -1) + .join(slashSymbol) + localEntries = await readDir(cleanedPath) + } catch (err) { + console.error('Failed to fetch local suggestions (again):', err) + } + } + + const extraSlash = cleanedPath.endsWith(slashSymbol) ? '' : slashSymbol + + const localSuggestions = localEntries + .filter((entry) => !entry.isSymlink) + .map((entry) => ({ + IsDir: entry.isDirectory, + Name: entry.name, + Path: `${cleanedPath}${extraSlash}${entry.name}`, + })) + + setSuggestions(localSuggestions) + + return + } + + // Split the path into remote and path parts + const [remote, ...pathParts] = searchPath.split(':/') + if (!remote) { + throw new Error('Invalid remote path format') + } + + let remotePath = pathParts.join('/') + if (remotePath.endsWith('/')) { + remotePath = remotePath.slice(0, -1) + } + + const items = await listPath(remote, remotePath, { + noModTime: true, + noMimeType: true, + }) + + const suggestionsWithRemote = items.map((item) => ({ + IsDir: item.IsDir, + Name: item.Path, + Path: `${remote}:/${item.Path}`, + })) + + setSuggestions(suggestionsWithRemote) + } catch (err) { + console.error('Failed to fetch suggestions:', err) + const errorMessage = + err instanceof Error ? err.message : 'Failed to fetch suggestions' + setError(errorMessage) + setSuggestions([]) + } finally { + setIsLoading(false) + } + }, + [remotes] + ) + + const handleBrowse = useCallback(async () => { + try { + await lockWindows() + const selected = await open({ + directory: true, + multiple: false, + defaultPath: path, + title: 'Select a folder', + }) + await unlockWindows() + if (selected) { + setPath(selected as string) + } + } catch (err) { + console.error('Failed to open folder picker:', err) + setError('Failed to open folder picker') + } + }, [path, setPath]) + + useEffect(() => { + let cancelTimeout: ReturnType | null = null + if (!showSuggestions) { + return + } + if (debouncedPath) { + fetchSuggestions(debouncedPath).then(() => { + if (fieldRef.current) { + fieldRef.current.blur() + cancelTimeout = setTimeout(() => { + fieldRef.current?.focus() + }, 100) + } + }) + } + return () => { + if (cancelTimeout) { + clearTimeout(cancelTimeout) + cancelTimeout = null + } + } + }, [debouncedPath, fetchSuggestions, showSuggestions]) + + return ( +
+
+ { + setPath(e) + }} + onFocus={() => { + if (!path) { + fetchSuggestions('') + } + }} + shouldCloseOnBlur={false} + placeholder={placeholder} + isInvalid={!!error} + errorMessage={error} + isLoading={isLoading} + selectorIcon={showSuggestions ? undefined : null} + isClearable={clearable} + onClear={() => { + setPath('') + setSuggestions([]) + setError(null) + }} + > + {visibleSuggestions.map((item, index) => ( + + ))} + +
+ {pickerEnabled && ( + + )} +
+ ) +} + +function MultiPathField({ + paths, + setPaths, + label, + placeholder = 'Enter a remote:/path or local path, or tap to select files', + showSuggestions = true, + clearable = true, +}: { + paths: string[] + setPaths: (paths: string[] | undefined) => void + label: string + placeholder?: string + showSuggestions?: boolean + clearable?: boolean +}) { + const remotes = useStore((state) => state.remotes) + + const [debouncedPath] = useDebounce(paths?.[0], 200) + + const fieldRef = useRef(null) + + const isMultiple = useMemo(() => paths.length > 1, [paths]) + + const [suggestions, setSuggestions] = useState< + { + IsDir: boolean + Name: string + Path: string + }[] + >([]) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const fetchSuggestions = useCallback( + async (path: string) => { + setIsLoading(true) + setError(null) // console.log('fetching suggestions for', path, field) @@ -131,7 +490,7 @@ export default function PathFinder({ Name: remote + ':/', Path: remote + ':/', })) - setSuggestions((prev) => ({ ...prev, [field]: remoteItems })) + setSuggestions(remoteItems) return } @@ -164,7 +523,7 @@ export default function PathFinder({ Path: `${cleanedPath}${extraSlash}${entry.name}`, })) - setSuggestions((prev) => ({ ...prev, [field]: localSuggestions })) + setSuggestions(localSuggestions) return } @@ -191,145 +550,195 @@ export default function PathFinder({ Path: `${remote}:/${item.Path}`, })) - setSuggestions((prev) => ({ ...prev, [field]: suggestionsWithRemote })) + setSuggestions(suggestionsWithRemote) } catch (err) { console.error('Failed to fetch suggestions:', err) const errorMessage = err instanceof Error ? err.message : 'Failed to fetch suggestions' - setError((prev) => ({ - ...prev, - [field]: errorMessage, - })) - setSuggestions((prev) => ({ ...prev, [field]: [] })) + setError(errorMessage) + setSuggestions([]) } finally { - setIsLoading((prev) => ({ ...prev, [field]: false })) + setIsLoading(false) } }, [remotes] ) - const handlePathChange = useCallback( - (value: string, field: 'source' | 'dest') => { - if (field === 'source') { - setSourcePath(value) - } else { - setDestPath(value) + const handleBrowse = useCallback( + async (type: 'file' | 'folder') => { + try { + await lockWindows() + const selected = await open({ + directory: type === 'folder', + multiple: type === 'file', + defaultPath: paths?.[0], + title: type === 'file' ? 'Select one or more files' : 'Select a folder', + }) + await unlockWindows() + if (selected) { + if (typeof selected === 'string') { + setPaths([selected]) + } else { + setPaths(selected) + } + } + } catch (err) { + console.error('Failed to open folder picker:', err) + setError('Failed to open folder picker') } }, - [setDestPath, setSourcePath] + [paths, setPaths] ) - useEffect(() => { - fetchSuggestions(sourcePath, 'source') - }, [sourcePath, fetchSuggestions]) + const visibleSuggestions = useMemo(() => { + if (!showSuggestions) { + return [] + } + return suggestions + }, [suggestions, showSuggestions]) useEffect(() => { - fetchSuggestions(destPath, 'dest') - }, [destPath, fetchSuggestions]) + if (!showSuggestions) { + return + } + if (isMultiple) { + return + } + if (paths?.[0]) { + fetchSuggestions(paths?.[0]) + } + }, [paths, fetchSuggestions, showSuggestions, isMultiple]) - //! suggestions close because component re-renders - const renderField = useCallback( - (field: 'source' | 'dest') => { - const isSource = field === 'source' - - const value = isSource ? sourcePath : destPath - const setValue = (newValue: string) => handlePathChange(newValue, field) - const isFieldLoading = isLoading[field] - const fieldError = error[field] - - const remoteSuggestionsEnabled = isSource - ? sourceOptions.remoteSuggestions - : destOptions.remoteSuggestions - - const fieldSuggestions = remoteSuggestionsEnabled ? suggestions[field] : [] - - const pickerEnabled = isSource ? sourceOptions.folderPicker : destOptions.folderPicker - - const isClearable = isSource ? sourceOptions.clearable : destOptions.clearable - - return ( -
-
- setValue(e)} - onFocus={() => { - if (!value) { - fetchSuggestions('', field) - } - }} - shouldCloseOnBlur={false} - placeholder={ - isSource ? sourceOptions.placeholder : destOptions.placeholder - } - isInvalid={!!fieldError} - errorMessage={fieldError} - isLoading={isFieldLoading} - selectorIcon={remoteSuggestionsEnabled ? undefined : null} - isClearable={isClearable} - > - {fieldSuggestions.map((item, index) => ( - - ))} - -
- {pickerEnabled && ( - - )} -
- ) - }, - [ - sourcePath, - destPath, - suggestions, - isLoading, - error, - fetchSuggestions, - handleBrowse, - handlePathChange, - sourceOptions, - destOptions, - ] - ) + useEffect(() => { + let cancelTimeout: ReturnType | null = null + if (!showSuggestions) { + return + } + if (isMultiple) { + return + } + if (debouncedPath) { + fetchSuggestions(debouncedPath).then(() => { + if (fieldRef.current) { + fieldRef.current.blur() + cancelTimeout = setTimeout(() => { + fieldRef.current?.focus() + }, 100) + } + }) + } + return () => { + if (cancelTimeout) { + clearTimeout(cancelTimeout) + cancelTimeout = null + } + } + }, [debouncedPath, fetchSuggestions, showSuggestions, isMultiple]) return ( -
- {renderField('source')} - - {switchable && ( -
- + - )} - - {renderField('dest')} +
) }