commander search
This commit is contained in:
@@ -210,7 +210,7 @@ export default function FileList({
|
||||
return (
|
||||
<ListboxItem
|
||||
key={entry.key}
|
||||
textValue={entry.name}
|
||||
textValue={entry.displayName ?? entry.name}
|
||||
classNames={{
|
||||
base: 'p-0 m-0 rounded-none !outline-none data-[focus-visible=true]:!outline-none focus:!outline-none',
|
||||
title: 'h-full justify-center flex',
|
||||
@@ -246,7 +246,9 @@ export default function FileList({
|
||||
onClick={() => onNavigate(entry)}
|
||||
>
|
||||
<FileIcon entry={entry} size="md" />
|
||||
<span className="truncate !cursor-pointer">{entry.name}</span>
|
||||
<span className="truncate !cursor-pointer" title={entry.fullPath}>
|
||||
{entry.displayName ?? entry.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="truncate text-small text-default-500">
|
||||
|
||||
@@ -406,8 +406,8 @@ const FilePanel = forwardRef<
|
||||
<div ref={listRef} className="relative flex-1 w-full overflow-hidden">
|
||||
<FileList
|
||||
items={nav.virtualizedItems}
|
||||
isLoading={nav.isLoading}
|
||||
error={nav.error}
|
||||
isLoading={nav.isLoading || nav.isSearching}
|
||||
error={nav.recursiveSearchActive ? nav.searchError : nav.error}
|
||||
selectedKeys={nav.selectedPaths}
|
||||
onToggleSelect={nav.handleToggleSelect}
|
||||
onNavigate={nav.handleNavigate}
|
||||
@@ -436,6 +436,8 @@ const FilePanel = forwardRef<
|
||||
isLoading={nav.isLoading}
|
||||
searchTerm={nav.searchTerm}
|
||||
onSearchChange={nav.setSearchTerm}
|
||||
searchInSubfolders={nav.searchInSubfolders}
|
||||
onSearchInSubfoldersChange={nav.setSearchInSubfolders}
|
||||
renderToolbar={renderToolbar}
|
||||
visible={toolbarVisible && nav.selectedRemote !== 'UI_FAVORITES'}
|
||||
newFolderButton={newFolderButton}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Button, Input, Tooltip } from '@heroui/react'
|
||||
import { Button, Checkbox, Input, Tooltip } from '@heroui/react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ArrowLeftIcon, RefreshCwIcon } from 'lucide-react'
|
||||
import { ArrowLeftIcon, RefreshCwIcon, XIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type ToolbarButtons = {
|
||||
@@ -17,6 +17,8 @@ export default function PanelToolbar({
|
||||
isLoading,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
searchInSubfolders,
|
||||
onSearchInSubfoldersChange,
|
||||
renderToolbar,
|
||||
visible = true,
|
||||
newFolderButton,
|
||||
@@ -27,6 +29,8 @@ export default function PanelToolbar({
|
||||
isLoading: boolean
|
||||
searchTerm: string
|
||||
onSearchChange: (term: string) => void
|
||||
searchInSubfolders: boolean
|
||||
onSearchInSubfoldersChange: (selected: boolean) => void
|
||||
renderToolbar?: (buttons: ToolbarButtons) => ReactNode[][]
|
||||
visible?: boolean
|
||||
newFolderButton?: ReactNode
|
||||
@@ -63,6 +67,35 @@ export default function PanelToolbar({
|
||||
)
|
||||
|
||||
const SearchInput = (
|
||||
<div className="relative w-48 group">
|
||||
<div className="absolute z-20 flex items-center invisible gap-2 mb-3 transition-opacity opacity-0 pointer-events-none -left-2 bottom-full group-focus-within:visible group-focus-within:opacity-100 group-focus-within:pointer-events-auto">
|
||||
<div className="px-3.5 pt-0.5 pb-1.5 rounded-full bg-content2 shadow-medium">
|
||||
<Checkbox
|
||||
size="sm"
|
||||
isSelected={searchInSubfolders}
|
||||
onValueChange={onSearchInSubfoldersChange}
|
||||
classNames={{ label: 'text-xs whitespace-nowrap' }}
|
||||
>
|
||||
Search in sub-folders
|
||||
</Checkbox>
|
||||
</div>
|
||||
<Button
|
||||
isIconOnly={true}
|
||||
color="danger"
|
||||
size="sm"
|
||||
radius="full"
|
||||
className="min-w-7 size-7"
|
||||
aria-label="Close search options"
|
||||
onPress={() => {
|
||||
onSearchInSubfoldersChange(false)
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
size="sm"
|
||||
radius="full"
|
||||
@@ -76,16 +109,20 @@ export default function PanelToolbar({
|
||||
autoCorrect="off"
|
||||
spellCheck="false"
|
||||
classNames={{
|
||||
base: 'w-48',
|
||||
base: 'w-full',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
const NewFolderButton = newFolderButton ?? null
|
||||
const buttons: ToolbarButtons = { BackButton, RefreshButton, SearchInput, NewFolderButton }
|
||||
const groups = renderToolbar
|
||||
? renderToolbar(buttons)
|
||||
: [[BackButton, RefreshButton], [SearchInput, NewFolderButton]]
|
||||
: [
|
||||
[BackButton, RefreshButton],
|
||||
[SearchInput, NewFolderButton],
|
||||
]
|
||||
|
||||
const motionTransition = {
|
||||
enter: {
|
||||
|
||||
@@ -5,6 +5,7 @@ export type RemoteString = string | 'UI_LOCAL_FS' | 'UI_FAVORITES' | null
|
||||
export type Entry = {
|
||||
key: string
|
||||
name: string
|
||||
displayName?: string
|
||||
isDir: boolean
|
||||
size?: number
|
||||
modTime?: string
|
||||
|
||||
@@ -4,6 +4,7 @@ import { homeDir } from '@tauri-apps/api/path'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import rclone from '../../../lib/rclone/client.ts'
|
||||
import { useHostStore } from '../../../store/host.ts'
|
||||
import { useCurrentHost } from '../../../store/persisted.ts'
|
||||
import type {
|
||||
AllowedKey,
|
||||
Entry,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
joinLocal,
|
||||
listRemotePath,
|
||||
log,
|
||||
searchPath,
|
||||
serializeRemotePath,
|
||||
} from './utils'
|
||||
|
||||
@@ -48,6 +50,7 @@ export default function useFileNavigation({
|
||||
isActive?: boolean
|
||||
}) {
|
||||
const favoritePaths = useHostStore((state) => state.favoritePaths)
|
||||
const currentHost = useCurrentHost()
|
||||
|
||||
const remotesQuery = useQuery({
|
||||
queryKey: ['remotes', 'list', 'all'],
|
||||
@@ -61,6 +64,10 @@ export default function useFileNavigation({
|
||||
const [cwd, setCwd] = useState<string>(initialPath ?? '')
|
||||
const [pathInput, setPathInput] = useState<string>('')
|
||||
const [searchTerm, setSearchTerm] = useState<string>('')
|
||||
const [searchInSubfolders, setSearchInSubfolders] = useState(false)
|
||||
const [recursiveSearchItems, setRecursiveSearchItems] = useState<Entry[] | null>(null)
|
||||
const [isSearching, setIsSearching] = useState(false)
|
||||
const [searchError, setSearchError] = useState<string | null>(null)
|
||||
const [sortDescriptor, setSortDescriptor] = useState<{
|
||||
column: 'name' | 'size' | 'modTime'
|
||||
direction: 'ascending' | 'descending'
|
||||
@@ -91,14 +98,19 @@ export default function useFileNavigation({
|
||||
const localRequestIdRef = useRef<string | null>(null)
|
||||
const localRequestPrefixRef = useRef(Math.random().toString(36).slice(2))
|
||||
const localRequestSequenceRef = useRef(0)
|
||||
const searchRequestSequenceRef = useRef(0)
|
||||
|
||||
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set())
|
||||
const selectedTypesRef = useRef<Map<string, 'file' | 'folder'>>(new Map())
|
||||
|
||||
const recursiveSearchActive = searchInSubfolders && searchTerm.trim().length > 0
|
||||
|
||||
const visibleItems = useMemo(() => {
|
||||
const base = allowFiles ? items : items.filter((it) => it.isDir)
|
||||
const lower = searchTerm.toLowerCase()
|
||||
const filtered = searchTerm
|
||||
const sourceItems = recursiveSearchActive ? (recursiveSearchItems ?? []) : items
|
||||
const base = allowFiles ? sourceItems : sourceItems.filter((it) => it.isDir)
|
||||
const normalizedSearchTerm = recursiveSearchActive ? searchTerm.trim() : searchTerm
|
||||
const lower = normalizedSearchTerm.toLowerCase()
|
||||
const filtered = normalizedSearchTerm
|
||||
? base.filter((item) => item.name.toLowerCase().includes(lower))
|
||||
: base
|
||||
const direction = sortDescriptor.direction === 'ascending' ? 1 : -1
|
||||
@@ -106,9 +118,11 @@ export default function useFileNavigation({
|
||||
return [...filtered].sort((a, b) => {
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
|
||||
|
||||
const aName = a.displayName ?? a.name
|
||||
const bName = b.displayName ?? b.name
|
||||
const nameComparison =
|
||||
nameCollator.compare(a.name, b.name) ||
|
||||
a.name.localeCompare(b.name) ||
|
||||
nameCollator.compare(aName, bName) ||
|
||||
aName.localeCompare(bName) ||
|
||||
a.key.localeCompare(b.key)
|
||||
|
||||
if (sortDescriptor.column === 'name') return nameComparison * direction
|
||||
@@ -145,7 +159,14 @@ export default function useFileNavigation({
|
||||
}
|
||||
return nameComparison
|
||||
})
|
||||
}, [allowFiles, items, searchTerm, sortDescriptor])
|
||||
}, [
|
||||
allowFiles,
|
||||
items,
|
||||
recursiveSearchActive,
|
||||
recursiveSearchItems,
|
||||
searchTerm,
|
||||
sortDescriptor,
|
||||
])
|
||||
|
||||
const handleSort = useCallback((column: 'name' | 'size' | 'modTime') => {
|
||||
setSortDescriptor((current) => ({
|
||||
@@ -256,6 +277,13 @@ export default function useFileNavigation({
|
||||
return
|
||||
}
|
||||
isNavigatingRef.current = true
|
||||
if (recursiveSearchActive) {
|
||||
const resultPath = isRemote
|
||||
? entry.fullPath.split(':/').slice(1).join('/')
|
||||
: entry.fullPath
|
||||
startTransition(() => setCwd(resultPath))
|
||||
return
|
||||
}
|
||||
if (isRemote) {
|
||||
const base = cwd ? `${cwd}/` : ''
|
||||
startTransition(() => setCwd(`${base}${entry.name}`))
|
||||
@@ -264,7 +292,7 @@ export default function useFileNavigation({
|
||||
startTransition(() => setCwd(newPath))
|
||||
}
|
||||
},
|
||||
[selectedRemote, isRemote, cwd, cleanupSelectionForRemote]
|
||||
[selectedRemote, recursiveSearchActive, isRemote, cwd, cleanupSelectionForRemote]
|
||||
)
|
||||
|
||||
const navigateUp = useCallback(async () => {
|
||||
@@ -388,6 +416,124 @@ export default function useFileNavigation({
|
||||
setRefreshKey((k) => k + 1)
|
||||
}, [selectedRemote, cwd])
|
||||
|
||||
useEffect(() => {
|
||||
const requestSequence = ++searchRequestSequenceRef.current
|
||||
const term = searchTerm.trim()
|
||||
|
||||
if (
|
||||
!isActive ||
|
||||
!searchInSubfolders ||
|
||||
!term ||
|
||||
!selectedRemote ||
|
||||
selectedRemote === 'UI_FAVORITES'
|
||||
) {
|
||||
startTransition(() => {
|
||||
setRecursiveSearchItems(null)
|
||||
setSearchError(null)
|
||||
setIsSearching(false)
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
startTransition(() => {
|
||||
setRecursiveSearchItems(null)
|
||||
setSearchError(null)
|
||||
setIsSearching(true)
|
||||
})
|
||||
|
||||
const timeoutId = setTimeout(async () => {
|
||||
try {
|
||||
const result = await searchPath(
|
||||
selectedRemote as string | 'UI_LOCAL_FS',
|
||||
cwd,
|
||||
term,
|
||||
controller.signal
|
||||
)
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
searchRequestSequenceRef.current !== requestSequence
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const lowerTerm = term.toLowerCase()
|
||||
const normalizedBase = cwd
|
||||
.replace(RE_BACKSLASH, '/')
|
||||
.replace(RE_TRAILING_SLASH, '')
|
||||
const nextItems = result
|
||||
.map((item) => {
|
||||
const relativePath = String(item.Path || item.Name || '')
|
||||
.replace(RE_LEADING_SLASH, '')
|
||||
const name = String(item.Name || relativePath.split('/').pop() || '')
|
||||
if (
|
||||
!relativePath ||
|
||||
!name ||
|
||||
relativePath.split('/').some((part) => part.startsWith('.')) ||
|
||||
!name.toLowerCase().includes(lowerTerm)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const relativeToRoot = normalizedBase
|
||||
? `${normalizedBase}/${relativePath}`
|
||||
: relativePath
|
||||
const fullPath =
|
||||
selectedRemote === 'UI_LOCAL_FS'
|
||||
? normalizedBase
|
||||
? relativeToRoot
|
||||
: `/${relativePath}`
|
||||
: serializeRemotePath(selectedRemote as string, relativeToRoot)
|
||||
|
||||
return {
|
||||
key: fullPath,
|
||||
name,
|
||||
displayName: relativePath,
|
||||
isDir: !!(item.IsDir || item.IsBucket),
|
||||
size: typeof item.Size === 'number' ? item.Size : undefined,
|
||||
modTime: item.ModTime,
|
||||
mimeType: item.MimeType,
|
||||
remote: selectedRemote,
|
||||
fullPath,
|
||||
} as Entry
|
||||
})
|
||||
.filter((item): item is Entry => item !== null)
|
||||
|
||||
const map = entryByKeyRef.current
|
||||
for (const entry of nextItems) map.set(entry.key, entry)
|
||||
startTransition(() => {
|
||||
setRecursiveSearchItems(nextItems)
|
||||
setIsSearching(false)
|
||||
})
|
||||
} catch {
|
||||
if (
|
||||
controller.signal.aborted ||
|
||||
searchRequestSequenceRef.current !== requestSequence
|
||||
) {
|
||||
return
|
||||
}
|
||||
startTransition(() => {
|
||||
setRecursiveSearchItems([])
|
||||
setSearchError('Unable to search this folder')
|
||||
setIsSearching(false)
|
||||
})
|
||||
}
|
||||
}, 350)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId)
|
||||
controller.abort()
|
||||
}
|
||||
}, [
|
||||
currentHost?.id,
|
||||
cwd,
|
||||
isActive,
|
||||
searchInSubfolders,
|
||||
searchTerm,
|
||||
refreshKey,
|
||||
selectedRemote,
|
||||
])
|
||||
|
||||
// Initialize once per activation. The guard is set inside the branches (the remotes branch
|
||||
// only once the list has loaded, so late data can still finish the job) — after that, dep
|
||||
// churn (e.g. a /config/listremotes refetch minting a new `remotes` identity) can no longer
|
||||
@@ -844,9 +990,13 @@ export default function useFileNavigation({
|
||||
visibleItems,
|
||||
virtualizedItems,
|
||||
isLoading,
|
||||
isSearching,
|
||||
error,
|
||||
searchError,
|
||||
isUpDisabled,
|
||||
searchTerm,
|
||||
searchInSubfolders,
|
||||
recursiveSearchActive,
|
||||
sortDescriptor,
|
||||
selectedPaths,
|
||||
selectedCount,
|
||||
@@ -860,6 +1010,7 @@ export default function useFileNavigation({
|
||||
// Actions
|
||||
setPathInput,
|
||||
setSearchTerm,
|
||||
setSearchInSubfolders,
|
||||
handleSort,
|
||||
handleNavigate,
|
||||
navigateUp,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
UsbIcon,
|
||||
} from 'lucide-react'
|
||||
import { createRef } from 'react'
|
||||
import { getFsInfo } from '../../../lib/format.ts'
|
||||
import rclone from '../../../lib/rclone/client.ts'
|
||||
import type { SelectItem } from './types'
|
||||
|
||||
@@ -42,6 +43,7 @@ export const RE_TRAILING_SLASH = /\/+$/g
|
||||
export const RE_LEADING_SLASH = /^\/+/
|
||||
export const RE_PATH_SEPARATOR = /[/\\]/
|
||||
export const RE_TRAILING_SEPARATORS = /[\\/]+$/
|
||||
const RE_RCLONE_GLOB_META = /[\\*?[\]{}]/g
|
||||
|
||||
export const VIRTUAL_PADDING_COUNT = 2
|
||||
|
||||
@@ -156,6 +158,63 @@ export async function listRemotePath(
|
||||
return { list: deduped, baseDir: base }
|
||||
}
|
||||
|
||||
export async function searchPath(
|
||||
remote: string | 'UI_LOCAL_FS',
|
||||
dir: string,
|
||||
term: string,
|
||||
signal: AbortSignal
|
||||
) {
|
||||
const isLocal = remote === 'UI_LOCAL_FS'
|
||||
const localInfo = isLocal ? getFsInfo(dir) : null
|
||||
const fs = localInfo
|
||||
? localInfo.root === ':local:'
|
||||
? ':local:/'
|
||||
: localInfo.root
|
||||
: `${remote}:`
|
||||
const base = localInfo ? localInfo.filePath : normalizeRemoteDir(dir)
|
||||
const escapedTerm = term.replace(RE_RCLONE_GLOB_META, '\\$&')
|
||||
|
||||
const run = async (target: string) => {
|
||||
const result = await rclone('/operations/list', {
|
||||
params: {
|
||||
query: {
|
||||
fs,
|
||||
remote: target,
|
||||
opt: JSON.stringify({
|
||||
recurse: true,
|
||||
noModTime: false,
|
||||
noMimeType: true,
|
||||
}),
|
||||
_filter: JSON.stringify({
|
||||
IncludeRule: [`*${escapedTerm}*`],
|
||||
IgnoreCase: true,
|
||||
}),
|
||||
} as any,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
return Array.isArray(result) ? result : result?.list
|
||||
}
|
||||
|
||||
let list: any[] | undefined
|
||||
try {
|
||||
list = await run(base)
|
||||
} catch (error) {
|
||||
if (signal.aborted || !base) throw error
|
||||
list = await run(`${base}/`)
|
||||
}
|
||||
|
||||
if (!Array.isArray(list)) throw new Error('Invalid search response')
|
||||
|
||||
const seen = new Set<string>()
|
||||
return (list as any[]).filter((item) => {
|
||||
const path = (item?.Path || item?.Name || '') as string
|
||||
if (!path || seen.has(path)) return false
|
||||
seen.add(path)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export async function listLocalPath(dir: string) {
|
||||
log('listLocalPath', { dir })
|
||||
const entries = await readDir(dir)
|
||||
|
||||
Reference in New Issue
Block a user