From a72fd8e155df2c623972405542fe26ee92912451 Mon Sep 17 00:00:00 2001 From: FTCHD <144691102+FTCHD@users.noreply.github.com> Date: Mon, 13 Jul 2026 03:36:01 +0300 Subject: [PATCH] commander search --- src/components/navigator/FileList.tsx | 6 +- src/components/navigator/FilePanel.tsx | 6 +- src/components/navigator/PanelToolbar.tsx | 75 ++++++-- src/components/navigator/types.ts | 1 + src/components/navigator/useFileNavigation.ts | 165 +++++++++++++++++- src/components/navigator/utils.ts | 59 +++++++ 6 files changed, 282 insertions(+), 30 deletions(-) diff --git a/src/components/navigator/FileList.tsx b/src/components/navigator/FileList.tsx index ca57c03..475d0fb 100644 --- a/src/components/navigator/FileList.tsx +++ b/src/components/navigator/FileList.tsx @@ -210,7 +210,7 @@ export default function FileList({ return ( onNavigate(entry)} > - {entry.name} + + {entry.displayName ?? entry.name} +
diff --git a/src/components/navigator/FilePanel.tsx b/src/components/navigator/FilePanel.tsx index 0257f89..4f05504 100644 --- a/src/components/navigator/FilePanel.tsx +++ b/src/components/navigator/FilePanel.tsx @@ -406,8 +406,8 @@ const FilePanel = forwardRef<
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 = ( - onSearchChange('')} - autoCapitalize="off" - autoComplete="off" - autoCorrect="off" - spellCheck="false" - classNames={{ - base: 'w-48', - }} - /> +
+
+
+ + Search in sub-folders + +
+ +
+ onSearchChange('')} + autoCapitalize="off" + autoComplete="off" + autoCorrect="off" + spellCheck="false" + classNames={{ + base: 'w-full', + }} + /> +
) 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: { diff --git a/src/components/navigator/types.ts b/src/components/navigator/types.ts index 13ce8f0..6f43938 100644 --- a/src/components/navigator/types.ts +++ b/src/components/navigator/types.ts @@ -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 diff --git a/src/components/navigator/useFileNavigation.ts b/src/components/navigator/useFileNavigation.ts index 0231609..5919778 100644 --- a/src/components/navigator/useFileNavigation.ts +++ b/src/components/navigator/useFileNavigation.ts @@ -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(initialPath ?? '') const [pathInput, setPathInput] = useState('') const [searchTerm, setSearchTerm] = useState('') + const [searchInSubfolders, setSearchInSubfolders] = useState(false) + const [recursiveSearchItems, setRecursiveSearchItems] = useState(null) + const [isSearching, setIsSearching] = useState(false) + const [searchError, setSearchError] = useState(null) const [sortDescriptor, setSortDescriptor] = useState<{ column: 'name' | 'size' | 'modTime' direction: 'ascending' | 'descending' @@ -91,14 +98,19 @@ export default function useFileNavigation({ const localRequestIdRef = useRef(null) const localRequestPrefixRef = useRef(Math.random().toString(36).slice(2)) const localRequestSequenceRef = useRef(0) + const searchRequestSequenceRef = useRef(0) const [selectedPaths, setSelectedPaths] = useState>(new Set()) const selectedTypesRef = useRef>(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, diff --git a/src/components/navigator/utils.ts b/src/components/navigator/utils.ts index c78565e..12e236c 100644 --- a/src/components/navigator/utils.ts +++ b/src/components/navigator/utils.ts @@ -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() + 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)