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 && (
-
-
)
}