split Multi/PathFinder, debounce search, files picker
Signed-off-by: FTCHD <144691102+FTCHD@users.noreply.github.com>
This commit is contained in:
Generated
+13
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user