commander search

This commit is contained in:
FTCHD
2026-07-13 03:36:01 +03:00
parent c123727f3a
commit a72fd8e155
6 changed files with 282 additions and 30 deletions
+4 -2
View File
@@ -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">
+4 -2
View File
@@ -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}
+56 -19
View File
@@ -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,29 +67,62 @@ export default function PanelToolbar({
)
const SearchInput = (
<Input
size="sm"
radius="full"
placeholder="Type here to search"
value={searchTerm}
onValueChange={onSearchChange}
isClearable={true}
onClear={() => onSearchChange('')}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
classNames={{
base: 'w-48',
}}
/>
<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"
placeholder="Type here to search"
value={searchTerm}
onValueChange={onSearchChange}
isClearable={true}
onClear={() => onSearchChange('')}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
classNames={{
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: {
+1
View File
@@ -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
+158 -7
View File
@@ -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,
+59
View File
@@ -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)