815 lines
32 KiB
TypeScript
815 lines
32 KiB
TypeScript
import { useQuery } from '@tanstack/react-query'
|
|
import { Channel, invoke } from '@tauri-apps/api/core'
|
|
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 type {
|
|
AllowedKey,
|
|
Entry,
|
|
PaddingItem,
|
|
RemoteString,
|
|
SelectItem,
|
|
VirtualizedEntry,
|
|
} from './types'
|
|
import {
|
|
RE_BACKSLASH,
|
|
RE_LEADING_SLASH,
|
|
RE_PATH_SEPARATOR,
|
|
RE_TRAILING_SLASH,
|
|
VIRTUAL_PADDING_COUNT,
|
|
cacheKey,
|
|
getLocalParent,
|
|
getRemoteParent,
|
|
joinLocal,
|
|
listRemotePath,
|
|
log,
|
|
serializeRemotePath,
|
|
} from './utils'
|
|
|
|
export default function useFileNavigation({
|
|
initialRemote,
|
|
initialPath,
|
|
allowedKeys = ['REMOTES', 'LOCAL_FS', 'FAVORITES'],
|
|
allowFiles = true,
|
|
allowMultiple = true,
|
|
isActive = true,
|
|
}: {
|
|
initialRemote?: string | 'UI_LOCAL_FS'
|
|
initialPath?: string
|
|
allowedKeys?: AllowedKey[]
|
|
allowFiles?: boolean
|
|
allowMultiple?: boolean
|
|
isActive?: boolean
|
|
}) {
|
|
const favoritePaths = useHostStore((state) => state.favoritePaths)
|
|
|
|
const remotesQuery = useQuery({
|
|
queryKey: ['remotes', 'list', 'all'],
|
|
queryFn: async () => await rclone('/config/listremotes').then((r) => r?.remotes),
|
|
staleTime: 1000 * 60,
|
|
})
|
|
|
|
const remotes = useMemo(() => remotesQuery.data ?? [], [remotesQuery.data])
|
|
|
|
const [selectedRemote, setSelectedRemote] = useState<RemoteString>(initialRemote ?? null)
|
|
const [cwd, setCwd] = useState<string>(initialPath ?? '')
|
|
const [pathInput, setPathInput] = useState<string>('')
|
|
const [searchTerm, setSearchTerm] = useState<string>('')
|
|
const [items, setItems] = useState<Entry[]>([])
|
|
const [isLoading, setIsLoading] = useState<boolean>(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [isUpDisabled, setIsUpDisabled] = useState(false)
|
|
const [refreshKey, setRefreshKey] = useState(0)
|
|
|
|
const isRemote = useMemo(
|
|
() =>
|
|
selectedRemote !== 'UI_LOCAL_FS' &&
|
|
selectedRemote !== 'UI_FAVORITES' &&
|
|
selectedRemote !== null,
|
|
[selectedRemote]
|
|
)
|
|
const canShowFavorites = useMemo(() => allowedKeys.includes('FAVORITES'), [allowedKeys])
|
|
const canShowLocal = useMemo(() => allowedKeys.includes('LOCAL_FS'), [allowedKeys])
|
|
const canShowRemotes = useMemo(() => allowedKeys.includes('REMOTES'), [allowedKeys])
|
|
|
|
const cacheRef = useRef<Map<string, Entry[]>>(new Map())
|
|
const localCompleteCacheRef = useRef<Set<string>>(new Set())
|
|
const entryByKeyRef = useRef<Map<string, Entry>>(new Map())
|
|
const abortControllerRef = useRef<AbortController | null>(null)
|
|
const loadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
const isNavigatingRef = useRef(false)
|
|
const localRequestIdRef = useRef<string | null>(null)
|
|
const localRequestPrefixRef = useRef(Math.random().toString(36).slice(2))
|
|
const localRequestSequenceRef = useRef(0)
|
|
|
|
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set())
|
|
const selectedTypesRef = useRef<Map<string, 'file' | 'folder'>>(new Map())
|
|
|
|
const visibleItems = useMemo(() => {
|
|
const base = allowFiles ? items : items.filter((it) => it.isDir)
|
|
if (!searchTerm) return base
|
|
const lower = searchTerm.toLowerCase()
|
|
return base.filter((item) => item.name.toLowerCase().includes(lower))
|
|
}, [allowFiles, items, searchTerm])
|
|
|
|
const virtualizedItems: (VirtualizedEntry | PaddingItem)[] = useMemo(() => {
|
|
const base: (VirtualizedEntry | PaddingItem)[] = visibleItems.map((item) => ({
|
|
...item,
|
|
isSelected: selectedPaths.has(item.key),
|
|
}))
|
|
for (let i = 0; i < VIRTUAL_PADDING_COUNT; i++) {
|
|
base.push({ key: `__padding-${i}`, padding: true })
|
|
}
|
|
return base
|
|
}, [visibleItems, selectedPaths])
|
|
|
|
const selectedCount = useMemo(() => selectedPaths.size, [selectedPaths])
|
|
|
|
const favoritedKeys = useMemo(() => {
|
|
const map: Record<string, boolean> = {}
|
|
for (const it of favoritePaths || []) {
|
|
const remote = (it as any).remote as string | undefined
|
|
const rawPath = (it as any).path as string
|
|
let fullKey = rawPath
|
|
if (rawPath?.includes(':/')) {
|
|
fullKey = rawPath
|
|
} else if (remote && remote !== 'UI_LOCAL_FS') {
|
|
const rel = (rawPath || '').replace(RE_LEADING_SLASH, '')
|
|
fullKey = serializeRemotePath(remote, rel)
|
|
} else {
|
|
fullKey = rawPath
|
|
}
|
|
if (fullKey) map[fullKey] = true
|
|
}
|
|
return map
|
|
}, [favoritePaths])
|
|
|
|
const cleanupSelectionForRemote = useCallback(
|
|
(newRemote: RemoteString) => {
|
|
log('cleanupSelectionForRemote', { newRemote, selectedRemote })
|
|
if (newRemote !== selectedRemote) {
|
|
startTransition(() => {
|
|
setSelectedPaths(new Set())
|
|
})
|
|
selectedTypesRef.current.clear()
|
|
const currentPrefix = selectedRemote === 'UI_LOCAL_FS' ? '' : `${selectedRemote}:`
|
|
const keysToRemove: string[] = []
|
|
for (const key of entryByKeyRef.current.keys()) {
|
|
if (selectedRemote === 'UI_LOCAL_FS' && !key.includes(':/')) {
|
|
keysToRemove.push(key)
|
|
} else if (selectedRemote !== 'UI_LOCAL_FS' && key.startsWith(currentPrefix)) {
|
|
keysToRemove.push(key)
|
|
}
|
|
}
|
|
for (const key of keysToRemove) {
|
|
entryByKeyRef.current.delete(key)
|
|
}
|
|
}
|
|
},
|
|
[selectedRemote]
|
|
)
|
|
|
|
const updatePathInput = useCallback((nextRemote: RemoteString, nextCwd: string) => {
|
|
if (!nextRemote) {
|
|
startTransition(() => setPathInput(''))
|
|
return
|
|
}
|
|
if (nextRemote === 'UI_FAVORITES') {
|
|
startTransition(() => setPathInput(''))
|
|
return
|
|
}
|
|
if (nextRemote === 'UI_LOCAL_FS') {
|
|
startTransition(() => setPathInput(nextCwd || ''))
|
|
} else {
|
|
startTransition(() => setPathInput(`${nextRemote}:/${nextCwd || ''}`))
|
|
}
|
|
}, [])
|
|
|
|
const handleNavigate = useCallback(
|
|
async (entry: Entry) => {
|
|
log('handleNavigate', { entry, isNavigating: isNavigatingRef.current, selectedRemote })
|
|
if (isNavigatingRef.current) return
|
|
if (!entry.isDir) return
|
|
if (!selectedRemote) return
|
|
if (selectedRemote === 'UI_FAVORITES') {
|
|
const full = entry.fullPath
|
|
if (full.includes(':/')) {
|
|
const [remoteName, ...rest] = full.split(':/')
|
|
const rel = rest.join('/')
|
|
cleanupSelectionForRemote(remoteName as any)
|
|
startTransition(() => {
|
|
setSelectedRemote(remoteName as any)
|
|
setCwd(rel)
|
|
})
|
|
} else {
|
|
cleanupSelectionForRemote('UI_LOCAL_FS')
|
|
startTransition(() => {
|
|
setSelectedRemote('UI_LOCAL_FS')
|
|
setCwd(full)
|
|
})
|
|
}
|
|
return
|
|
}
|
|
isNavigatingRef.current = true
|
|
if (isRemote) {
|
|
const base = cwd ? `${cwd}/` : ''
|
|
startTransition(() => setCwd(`${base}${entry.name}`))
|
|
} else {
|
|
const newPath = await joinLocal(cwd, entry.name)
|
|
startTransition(() => setCwd(newPath))
|
|
}
|
|
},
|
|
[selectedRemote, isRemote, cwd, cleanupSelectionForRemote]
|
|
)
|
|
|
|
const navigateUp = useCallback(async () => {
|
|
log('navigateUp', { cwd, selectedRemote })
|
|
if (!selectedRemote) return
|
|
if (isRemote) {
|
|
const parent = getRemoteParent(cwd)
|
|
startTransition(() => setCwd(parent))
|
|
}
|
|
if (selectedRemote === 'UI_LOCAL_FS') {
|
|
const parent = await getLocalParent(cwd)
|
|
startTransition(() => setCwd(parent))
|
|
return
|
|
}
|
|
}, [cwd, selectedRemote, isRemote])
|
|
|
|
const navigateTo = useCallback(
|
|
(path: string) => {
|
|
const value = path.trim()
|
|
if (!value) return
|
|
if (value.includes(':/')) {
|
|
const [remote, ...rest] = value.split(':/')
|
|
const rel = rest.join('/')
|
|
cleanupSelectionForRemote(remote)
|
|
startTransition(() => {
|
|
setSelectedRemote(remote)
|
|
setCwd(rel)
|
|
})
|
|
} else {
|
|
cleanupSelectionForRemote('UI_LOCAL_FS')
|
|
startTransition(() => {
|
|
setSelectedRemote('UI_LOCAL_FS')
|
|
setCwd(value)
|
|
})
|
|
}
|
|
},
|
|
[cleanupSelectionForRemote]
|
|
)
|
|
|
|
const selectRemote = useCallback(
|
|
async (remote: string | 'UI_LOCAL_FS' | 'UI_FAVORITES', initialPath?: string) => {
|
|
cleanupSelectionForRemote(remote)
|
|
if (remote === 'UI_LOCAL_FS') {
|
|
const startPath = initialPath ?? (await homeDir())
|
|
startTransition(() => {
|
|
setSelectedRemote(remote)
|
|
setCwd(startPath)
|
|
})
|
|
} else {
|
|
startTransition(() => {
|
|
setSelectedRemote(remote)
|
|
setCwd(initialPath ?? '')
|
|
})
|
|
}
|
|
},
|
|
[cleanupSelectionForRemote]
|
|
)
|
|
|
|
const handleToggleSelect = useCallback(
|
|
(entry: Entry) => {
|
|
const next = new Set(selectedPaths)
|
|
if (next.has(entry.key)) {
|
|
next.delete(entry.key)
|
|
selectedTypesRef.current.delete(entry.key)
|
|
} else {
|
|
if (!allowMultiple && next.size > 0) return
|
|
next.add(entry.key)
|
|
selectedTypesRef.current.set(entry.key, entry.isDir ? 'folder' : 'file')
|
|
}
|
|
startTransition(() => setSelectedPaths(next))
|
|
},
|
|
[selectedPaths, allowMultiple]
|
|
)
|
|
|
|
const getSelection = useCallback((): SelectItem[] => {
|
|
const paths = Array.from(selectedPaths)
|
|
return paths.map((path) => {
|
|
const known = selectedTypesRef.current.get(path)
|
|
if (known) return { path, type: known }
|
|
const entry = entryByKeyRef.current.get(path)
|
|
if (entry) return { path, type: entry.isDir ? 'folder' : 'file' }
|
|
if (path.includes(':/')) {
|
|
const rel = path.split(':/').slice(1).join('/')
|
|
return { path, type: rel.endsWith('/') ? 'folder' : 'file' }
|
|
}
|
|
return { path, type: 'file' }
|
|
})
|
|
}, [selectedPaths])
|
|
|
|
const clearSelection = useCallback(() => {
|
|
selectedTypesRef.current.clear()
|
|
startTransition(() => setSelectedPaths(new Set()))
|
|
}, [])
|
|
|
|
const selectAll = useCallback(
|
|
(type: 'files' | 'folders' | 'all') => {
|
|
const next = new Set(selectedPaths)
|
|
for (const item of visibleItems) {
|
|
if (type === 'files' && !item.isDir) {
|
|
next.add(item.key)
|
|
selectedTypesRef.current.set(item.key, 'file')
|
|
} else if (type === 'folders' && item.isDir) {
|
|
next.add(item.key)
|
|
selectedTypesRef.current.set(item.key, 'folder')
|
|
} else if (type === 'all') {
|
|
next.add(item.key)
|
|
selectedTypesRef.current.set(item.key, item.isDir ? 'folder' : 'file')
|
|
}
|
|
}
|
|
startTransition(() => setSelectedPaths(next))
|
|
},
|
|
[selectedPaths, visibleItems]
|
|
)
|
|
|
|
const refresh = useCallback(() => {
|
|
const cKey = cacheKey(selectedRemote, cwd)
|
|
cacheRef.current.delete(cKey)
|
|
localCompleteCacheRef.current.delete(cKey)
|
|
startTransition(() => setItems([]))
|
|
setIsLoading(true)
|
|
setRefreshKey((k) => k + 1)
|
|
}, [selectedRemote, cwd])
|
|
|
|
// 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
|
|
// yank live navigation back to the initial location. Deliberately no effect cleanup:
|
|
// cancelling the pending homeDir() write would strand the panel on isLoading.
|
|
const hasInitializedRef = useRef(false)
|
|
useEffect(() => {
|
|
if (!isActive) {
|
|
// Deactivation re-arms initialization so a closed-and-reopened drawer (PathSelector
|
|
// passes isActive={isOpen}) still resets to its initial location.
|
|
hasInitializedRef.current = false
|
|
return
|
|
}
|
|
if (hasInitializedRef.current) return
|
|
|
|
const hasInitial = initialRemote !== undefined
|
|
const needsLocalPath = initialRemote === 'UI_LOCAL_FS' && !initialPath
|
|
|
|
if (needsLocalPath || (!hasInitial && canShowLocal)) {
|
|
hasInitializedRef.current = true
|
|
setIsLoading(true)
|
|
homeDir().then((home) => {
|
|
startTransition(() => {
|
|
setSelectedRemote('UI_LOCAL_FS')
|
|
setCwd(home)
|
|
setPathInput(home)
|
|
})
|
|
setIsLoading(false)
|
|
})
|
|
} else if (!hasInitial && canShowFavorites) {
|
|
hasInitializedRef.current = true
|
|
startTransition(() => setSelectedRemote('UI_FAVORITES'))
|
|
} else if (!hasInitial && canShowRemotes) {
|
|
// remotes still loading (empty list): stay uninitialized so the arrival re-run
|
|
// completes the initialization.
|
|
if (remotes.length > 0) {
|
|
hasInitializedRef.current = true
|
|
startTransition(() => {
|
|
setSelectedRemote(remotes[0])
|
|
setCwd('')
|
|
})
|
|
}
|
|
} else {
|
|
// hasInitial with a concrete remote/path: state was already seeded by the useState
|
|
// initializers; nothing to apply.
|
|
hasInitializedRef.current = true
|
|
}
|
|
}, [
|
|
isActive,
|
|
canShowLocal,
|
|
canShowFavorites,
|
|
canShowRemotes,
|
|
remotes,
|
|
initialRemote,
|
|
initialPath,
|
|
])
|
|
|
|
// Load directory content when remote/cwd changes
|
|
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey is an intentional re-run trigger the body doesn't read — refresh() evicts the cacheRef entry, clears items, and bumps it to force a refetch of the current directory; removing it breaks the Refresh button (empty panel, isLoading stuck true)
|
|
useEffect(() => {
|
|
if (!isActive) return
|
|
const cKey = cacheKey(selectedRemote, cwd)
|
|
|
|
async function loadDir() {
|
|
log('loadDir: start', { selectedRemote, cwd, isRemote })
|
|
if (!selectedRemote) {
|
|
startTransition(() => {
|
|
setItems([])
|
|
setError(null)
|
|
setIsLoading(false)
|
|
})
|
|
isNavigatingRef.current = false
|
|
return
|
|
}
|
|
|
|
if (abortControllerRef.current) abortControllerRef.current.abort()
|
|
if (loadingTimerRef.current) {
|
|
clearTimeout(loadingTimerRef.current)
|
|
loadingTimerRef.current = null
|
|
}
|
|
|
|
const controller = new AbortController()
|
|
abortControllerRef.current = controller
|
|
|
|
let finished = false
|
|
setError(null)
|
|
loadingTimerRef.current = setTimeout(() => {
|
|
if (!controller.signal.aborted && !finished) {
|
|
startTransition(() => setIsLoading(true))
|
|
}
|
|
}, 200)
|
|
|
|
if (cacheRef.current.has(cKey)) {
|
|
log('loadDir: cache hit', cKey)
|
|
const cached = cacheRef.current.get(cKey)!
|
|
startTransition(() => setItems(cached))
|
|
isNavigatingRef.current = false
|
|
if (
|
|
selectedRemote === 'UI_LOCAL_FS' &&
|
|
localCompleteCacheRef.current.has(cKey)
|
|
) {
|
|
finished = true
|
|
if (loadingTimerRef.current) {
|
|
clearTimeout(loadingTimerRef.current)
|
|
loadingTimerRef.current = null
|
|
}
|
|
setIsLoading(false)
|
|
return
|
|
}
|
|
}
|
|
|
|
let nextItems: Entry[] = []
|
|
if (selectedRemote === 'UI_FAVORITES') {
|
|
nextItems = (favoritePaths || []).map((fav) => {
|
|
const remote = (fav as any).remote as string | undefined
|
|
const isLocal = !remote || remote === 'UI_LOCAL_FS'
|
|
const rawPath = (fav as any).path as string
|
|
let fullPath = rawPath
|
|
if (!isLocal) {
|
|
if (rawPath.includes(':/')) {
|
|
fullPath = rawPath
|
|
} else {
|
|
const rel = (rawPath || '').replace(RE_LEADING_SLASH, '')
|
|
fullPath = serializeRemotePath(remote!, rel)
|
|
}
|
|
}
|
|
const relForName = (() => {
|
|
if (!isLocal) {
|
|
if (rawPath.includes(':/'))
|
|
return rawPath.split(':/').slice(1).join('/')
|
|
return rawPath
|
|
}
|
|
return rawPath
|
|
})()
|
|
const normalized = (relForName || '')
|
|
.replace(RE_BACKSLASH, '/')
|
|
.replace(RE_TRAILING_SLASH, '')
|
|
const baseName = normalized.split(RE_PATH_SEPARATOR).pop() || ''
|
|
const prefix = isLocal ? '(LOCAL)' : `(${remote})`
|
|
const addedLabel = `Added on ${new Date((fav as any).added).toLocaleString()}`
|
|
return {
|
|
key: fullPath,
|
|
name: `${prefix} ${baseName}`,
|
|
isDir: true,
|
|
size: undefined,
|
|
modTime: addedLabel,
|
|
remote: isLocal ? 'UI_LOCAL_FS' : remote,
|
|
fullPath,
|
|
} as Entry
|
|
})
|
|
nextItems.sort((a, b) => a.name.localeCompare(b.name))
|
|
if (!controller.signal.aborted) {
|
|
finished = true
|
|
if (loadingTimerRef.current) {
|
|
clearTimeout(loadingTimerRef.current)
|
|
loadingTimerRef.current = null
|
|
}
|
|
cacheRef.current.set(cKey, nextItems)
|
|
const map = entryByKeyRef.current
|
|
for (const e of nextItems) map.set(e.key, e)
|
|
startTransition(() => {
|
|
setItems(nextItems)
|
|
setIsLoading(false)
|
|
})
|
|
isNavigatingRef.current = false
|
|
}
|
|
return
|
|
}
|
|
|
|
if (isRemote) {
|
|
log('loadDir: fetching remote')
|
|
const remote = selectedRemote as string
|
|
const targetCwd = cwd || ''
|
|
let listedItems: { list: any[] } | null = null
|
|
try {
|
|
listedItems = await listRemotePath(remote, targetCwd, {
|
|
noModTime: false,
|
|
noMimeType: true,
|
|
})
|
|
} catch (_err: unknown) {
|
|
if (!controller.signal.aborted) {
|
|
finished = true
|
|
if (loadingTimerRef.current) {
|
|
clearTimeout(loadingTimerRef.current)
|
|
loadingTimerRef.current = null
|
|
}
|
|
startTransition(() => {
|
|
setIsLoading(false)
|
|
setItems([])
|
|
setError('No access or folder does not exist')
|
|
})
|
|
isNavigatingRef.current = false
|
|
}
|
|
return
|
|
}
|
|
|
|
if (controller.signal.aborted) {
|
|
log('loadDir: aborted after fetch')
|
|
return
|
|
}
|
|
|
|
nextItems = (listedItems?.list || [])
|
|
.map((it) => {
|
|
const rel = (it.Path || it.Name || '') as string
|
|
const baseName = rel.split('/').pop() || ''
|
|
const isDir = !!(it.IsDir || (it as any).IsBucket)
|
|
const full = serializeRemotePath(remote, rel)
|
|
return {
|
|
key: full,
|
|
name: baseName,
|
|
isDir,
|
|
size: it.Size,
|
|
modTime: it.ModTime,
|
|
mimeType: it.MimeType,
|
|
remote,
|
|
fullPath: full,
|
|
} as Entry
|
|
})
|
|
.filter((e) => !e.name.startsWith('.'))
|
|
} else if (cwd) {
|
|
const requestId = `${localRequestPrefixRef.current}-${++localRequestSequenceRef.current}`
|
|
localRequestIdRef.current = requestId
|
|
let streamedItems: Entry[] = []
|
|
let streamedIndexes = new Map<string, number>()
|
|
const pendingSizes = new Map<string, number | undefined>()
|
|
let sizeFrame: number | null = null
|
|
let streamFailed = false
|
|
const channel = new Channel<
|
|
| {
|
|
event: 'entries'
|
|
entries: {
|
|
name: string
|
|
fullPath: string
|
|
isDir: boolean
|
|
size: number | null
|
|
modTime: string | null
|
|
}[]
|
|
}
|
|
| { event: 'size'; fullPath: string; size: number | null }
|
|
| { event: 'error'; message: string }
|
|
| { event: 'complete' }
|
|
>()
|
|
const cancelSizeFrame = () => {
|
|
if (sizeFrame !== null) cancelAnimationFrame(sizeFrame)
|
|
sizeFrame = null
|
|
}
|
|
const flushSizeUpdates = () => {
|
|
if (controller.signal.aborted || pendingSizes.size === 0) {
|
|
pendingSizes.clear()
|
|
return
|
|
}
|
|
|
|
const nextItems = [...streamedItems]
|
|
for (const [fullPath, size] of pendingSizes) {
|
|
const index = streamedIndexes.get(fullPath)
|
|
if (index === undefined) continue
|
|
const updated = { ...nextItems[index], size }
|
|
nextItems[index] = updated
|
|
entryByKeyRef.current.set(updated.key, updated)
|
|
}
|
|
pendingSizes.clear()
|
|
streamedItems = nextItems
|
|
cacheRef.current.set(cKey, streamedItems)
|
|
startTransition(() => setItems(streamedItems))
|
|
}
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
channel.onmessage = (event) => {
|
|
if (event.event === 'complete') {
|
|
cancelSizeFrame()
|
|
flushSizeUpdates()
|
|
if (!controller.signal.aborted && !streamFailed) {
|
|
localCompleteCacheRef.current.add(cKey)
|
|
}
|
|
if (localRequestIdRef.current === requestId) {
|
|
localRequestIdRef.current = null
|
|
}
|
|
resolve()
|
|
return
|
|
}
|
|
if (controller.signal.aborted) return
|
|
if (event.event === 'error') {
|
|
streamFailed = true
|
|
cancelSizeFrame()
|
|
pendingSizes.clear()
|
|
cacheRef.current.delete(cKey)
|
|
localCompleteCacheRef.current.delete(cKey)
|
|
reject(new Error(event.message))
|
|
return
|
|
}
|
|
if (event.event === 'entries') {
|
|
streamedItems = event.entries.map(
|
|
(entry) =>
|
|
({
|
|
key: entry.fullPath,
|
|
name: entry.name,
|
|
isDir: entry.isDir,
|
|
size: entry.size ?? undefined,
|
|
modTime: entry.modTime ?? undefined,
|
|
remote: 'UI_LOCAL_FS',
|
|
fullPath: entry.fullPath,
|
|
}) as Entry
|
|
)
|
|
streamedItems.sort((a, b) => {
|
|
if (a.isDir && !b.isDir) return -1
|
|
if (!a.isDir && b.isDir) return 1
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
streamedIndexes = new Map(
|
|
streamedItems.map((entry, index) => [entry.fullPath, index])
|
|
)
|
|
finished = true
|
|
if (loadingTimerRef.current) {
|
|
clearTimeout(loadingTimerRef.current)
|
|
loadingTimerRef.current = null
|
|
}
|
|
cacheRef.current.set(cKey, streamedItems)
|
|
localCompleteCacheRef.current.delete(cKey)
|
|
const map = entryByKeyRef.current
|
|
for (const entry of streamedItems) map.set(entry.key, entry)
|
|
setItems(streamedItems)
|
|
setIsLoading(false)
|
|
isNavigatingRef.current = false
|
|
return
|
|
}
|
|
|
|
pendingSizes.set(event.fullPath, event.size ?? undefined)
|
|
if (sizeFrame === null) {
|
|
sizeFrame = requestAnimationFrame(() => {
|
|
sizeFrame = null
|
|
flushSizeUpdates()
|
|
})
|
|
}
|
|
}
|
|
|
|
invoke<void>('list_local_directory', {
|
|
path: cwd,
|
|
requestId,
|
|
onEvent: channel,
|
|
}).catch(reject)
|
|
})
|
|
} catch {
|
|
cacheRef.current.delete(cKey)
|
|
localCompleteCacheRef.current.delete(cKey)
|
|
if (!controller.signal.aborted) {
|
|
finished = true
|
|
if (loadingTimerRef.current) {
|
|
clearTimeout(loadingTimerRef.current)
|
|
loadingTimerRef.current = null
|
|
}
|
|
setIsLoading(false)
|
|
setItems([])
|
|
setError('No access or folder does not exist')
|
|
isNavigatingRef.current = false
|
|
}
|
|
return
|
|
} finally {
|
|
cancelSizeFrame()
|
|
pendingSizes.clear()
|
|
if (localRequestIdRef.current === requestId) {
|
|
localRequestIdRef.current = null
|
|
}
|
|
}
|
|
|
|
return
|
|
} else {
|
|
nextItems = []
|
|
}
|
|
|
|
if (controller.signal.aborted) return
|
|
|
|
nextItems.sort((a, b) => {
|
|
if (a.isDir && !b.isDir) return -1
|
|
if (!a.isDir && b.isDir) return 1
|
|
return a.name.localeCompare(b.name)
|
|
})
|
|
|
|
if (!controller.signal.aborted) {
|
|
finished = true
|
|
if (loadingTimerRef.current) {
|
|
clearTimeout(loadingTimerRef.current)
|
|
loadingTimerRef.current = null
|
|
}
|
|
cacheRef.current.set(cKey, nextItems)
|
|
const map = entryByKeyRef.current
|
|
for (const e of nextItems) map.set(e.key, e)
|
|
setItems(nextItems)
|
|
setIsLoading(false)
|
|
isNavigatingRef.current = false
|
|
}
|
|
}
|
|
|
|
loadDir()
|
|
|
|
return () => {
|
|
if (abortControllerRef.current) abortControllerRef.current.abort()
|
|
if (localRequestIdRef.current) {
|
|
cacheRef.current.delete(cKey)
|
|
localCompleteCacheRef.current.delete(cKey)
|
|
invoke('cancel_local_directory', {
|
|
requestId: localRequestIdRef.current,
|
|
}).catch(() => {})
|
|
localRequestIdRef.current = null
|
|
}
|
|
if (loadingTimerRef.current) {
|
|
clearTimeout(loadingTimerRef.current)
|
|
loadingTimerRef.current = null
|
|
}
|
|
}
|
|
}, [selectedRemote, cwd, isRemote, favoritePaths, isActive, refreshKey])
|
|
|
|
useEffect(() => {
|
|
updatePathInput(selectedRemote, cwd)
|
|
setSearchTerm('')
|
|
}, [selectedRemote, cwd, updatePathInput])
|
|
|
|
useEffect(() => {
|
|
let cancelled = false
|
|
async function updateUpState() {
|
|
if (!selectedRemote) {
|
|
if (!cancelled) setIsUpDisabled(true)
|
|
return
|
|
}
|
|
if (isRemote) {
|
|
if (!cancelled) setIsUpDisabled(cwd === '')
|
|
return
|
|
}
|
|
const current = cwd
|
|
if (!current) {
|
|
if (!cancelled) setIsUpDisabled(true)
|
|
return
|
|
}
|
|
let parent = ''
|
|
try {
|
|
parent = await getLocalParent(current)
|
|
} catch {
|
|
parent = current
|
|
}
|
|
if (cancelled) return
|
|
setIsUpDisabled(parent === current)
|
|
}
|
|
updateUpState()
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [selectedRemote, cwd, isRemote])
|
|
|
|
return {
|
|
// State
|
|
selectedRemote,
|
|
cwd,
|
|
pathInput,
|
|
items,
|
|
visibleItems,
|
|
virtualizedItems,
|
|
isLoading,
|
|
error,
|
|
isUpDisabled,
|
|
searchTerm,
|
|
selectedPaths,
|
|
selectedCount,
|
|
isRemote,
|
|
favoritedKeys,
|
|
remotes,
|
|
canShowFavorites,
|
|
canShowLocal,
|
|
canShowRemotes,
|
|
|
|
// Actions
|
|
setPathInput,
|
|
setSearchTerm,
|
|
handleNavigate,
|
|
navigateUp,
|
|
navigateTo,
|
|
selectRemote,
|
|
handleToggleSelect,
|
|
getSelection,
|
|
clearSelection,
|
|
selectAll,
|
|
refresh,
|
|
entryByKeyRef,
|
|
|
|
// For external control
|
|
setSelectedRemote,
|
|
setCwd,
|
|
setSelectedPaths,
|
|
}
|
|
}
|