split Multi/PathFinder, debounce search, files picker

Signed-off-by: FTCHD <144691102+FTCHD@users.noreply.github.com>
This commit is contained in:
FTCHD
2025-08-12 00:29:24 +02:00
parent 9205af78fd
commit abe9af1a88
3 changed files with 610 additions and 187 deletions
+13
View File
@@ -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",
+1
View File
@@ -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": {
+596 -187
View File
@@ -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 (
<div className="flex flex-col gap-8">
<PathField
path={sourcePath}
setPath={setSourcePath}
label={sourceOptions.label}
placeholder={sourceOptions.placeholder}
showSuggestions={sourceOptions.showSuggestions}
clearable={sourceOptions.clearable}
pickerEnabled={sourceOptions.showPicker}
/>
{switchable && (
<div className="flex justify-center">
<Button
onPress={handleSwap}
type="button"
isIconOnly={true}
size="lg"
isDisabled={!sourcePath && !destPath}
data-focus-visible="false"
>
<ArrowDownUp className="w-6 h-6" />
</Button>
</div>
)}
<PathField
path={destPath}
setPath={setDestPath}
label={destOptions.label}
placeholder={destOptions.placeholder}
showSuggestions={destOptions.showSuggestions}
clearable={destOptions.clearable}
pickerEnabled={destOptions.showPicker}
/>
</div>
)
}
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 (
<div className="flex flex-col gap-8">
<MultiPathField
paths={sourcePaths}
setPaths={setSourcePaths}
label={sourceOptions.label}
placeholder={sourceOptions.placeholder}
showSuggestions={sourceOptions.showSuggestions}
clearable={sourceOptions.clearable}
/>
{switchable && (
<div className="flex justify-center">
<Tooltip content={swapDisabledReason}>
<div>
<Button
onPress={handleSwap}
type="button"
isIconOnly={true}
size="lg"
isDisabled={isSwapDisabled}
data-focus-visible="false"
>
<ArrowDownUp className="w-6 h-6" />
</Button>
</div>
</Tooltip>
</div>
)}
<PathField
path={destPath}
setPath={setDestPath}
label={destOptions.label}
placeholder={destOptions.placeholder}
showSuggestions={destOptions.showSuggestions}
clearable={destOptions.clearable}
/>
</div>
)
}
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<HTMLInputElement>(null)
const [suggestions, setSuggestions] = useState<
{
IsDir: boolean
Name: string
Path: string
}[]
>([])
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(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<ReturnType<typeof readDir>> = []
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<typeof setTimeout> | 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 (
<div className="flex gap-2">
<div className="flex-1">
<Autocomplete
size="lg"
ref={fieldRef}
label={label}
allowsCustomValue={true}
inputValue={path || ''}
onInputChange={(e) => {
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) => (
<AutocompleteItem
startContent={item.IsDir ? '📁' : '📄'}
key={index}
textValue={item.Path}
title={item.Name}
/>
))}
</Autocomplete>
</div>
{pickerEnabled && (
<Button
onPress={handleBrowse}
type="button"
isIconOnly={true}
size="lg"
className="w-20 h-18"
data-focus-visible="false"
>
<FolderOpen className="w-6 h-6" />
</Button>
)}
</div>
)
}
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<HTMLInputElement>(null)
const isMultiple = useMemo(() => paths.length > 1, [paths])
const [suggestions, setSuggestions] = useState<
{
IsDir: boolean
Name: string
Path: string
}[]
>([])
const [isLoading, setIsLoading] = useState<boolean>(false)
const [error, setError] = useState<string | null>(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 (
<div className="flex gap-2">
<div className="flex-1">
<Autocomplete
size="lg"
label={isSource ? sourceOptions.label : destOptions.label}
allowsCustomValue={true}
inputValue={value}
onInputChange={(e) => 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) => (
<AutocompleteItem
startContent={item.IsDir ? '📁' : '📄'}
key={index}
textValue={item.Path}
title={item.Name}
/>
))}
</Autocomplete>
</div>
{pickerEnabled && (
<Button
onPress={() => handleBrowse(field)}
type="button"
isIconOnly={true}
size="lg"
className="w-16 h-18"
data-focus-visible="false"
>
<FolderOpen className="w-6 h-6" />
</Button>
)}
</div>
)
},
[
sourcePath,
destPath,
suggestions,
isLoading,
error,
fetchSuggestions,
handleBrowse,
handlePathChange,
sourceOptions,
destOptions,
]
)
useEffect(() => {
let cancelTimeout: ReturnType<typeof setTimeout> | 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 (
<div className="flex flex-col gap-8">
{renderField('source')}
{switchable && (
<div className="flex justify-center">
<Button
onPress={handleSwap}
type="button"
isIconOnly={true}
<div className="flex gap-2">
<div className="flex-1">
{isMultiple ? (
<div className="flex flex-col h-16 gap-0.5 p-2 overflow-y-auto bg-default-100 rounded-medium">
{paths.map((path, index) => (
<p key={index} className="text-xs text-foreground-600">
{index + 1}. {path}
</p>
))}
</div>
) : (
<Autocomplete
size="lg"
isDisabled={!sourcePath && !destPath}
data-focus-visible="false"
ref={fieldRef}
label={label}
allowsCustomValue={true}
inputValue={paths?.[0] || ''}
onInputChange={(e) => setPaths([e])}
onFocus={() => {
if (!paths?.[0]) {
fetchSuggestions('')
}
}}
shouldCloseOnBlur={false}
placeholder={placeholder}
isInvalid={!!error}
errorMessage={error}
isLoading={isLoading}
selectorIcon={showSuggestions ? undefined : null}
isClearable={clearable}
onClear={() => {
setPaths([])
setSuggestions([])
setError(null)
}}
>
<ArrowDownUp className="w-6 h-6" />
{visibleSuggestions.map((item, index) => (
<AutocompleteItem
startContent={item.IsDir ? '📁' : '📄'}
key={index}
textValue={item.Path}
title={item.Name}
/>
))}
</Autocomplete>
)}
</div>
<div className="relative w-20 group h-18">
<Button
onPress={() => {
if (isMultiple) {
setPaths([])
}
}}
type="button"
isIconOnly={true}
size="lg"
className={cn(
'absolute top-0 bottom-0 w-20 group-hover:hidden h-18',
isMultiple && 'group-hover:absolute'
)}
data-focus-visible="false"
>
{isMultiple ? (
<XIcon className="w-6 h-6" />
) : (
<FolderOpen className="w-6 h-6" />
)}
</Button>
<div
className={cn(
'absolute top-0 bottom-0 flex-col justify-between hidden w-20 group-hover:flex h-18',
isMultiple && 'group-hover:hidden'
)}
>
<Button
onPress={() => {
handleBrowse('file')
}}
type="button"
size="sm"
className="h-7"
data-focus-visible="false"
radius="lg"
variant="flat"
>
Files
</Button>
<Button
onPress={() => {
handleBrowse('folder')
}}
type="button"
size="sm"
className="h-7"
data-focus-visible="false"
radius="lg"
variant="flat"
>
Folder
</Button>
</div>
)}
{renderField('dest')}
</div>
</div>
)
}