Files
rclone-ui/src/pages/Commander.tsx
T
2026-08-20 01:13:47 +03:00

798 lines
31 KiB
TypeScript

import {
Button,
Checkbox,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Progress,
Radio,
RadioGroup,
ScrollShadow,
Tooltip,
} from '@heroui/react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core'
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
import { ask, save } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import { AnimatePresence, motion } from 'framer-motion'
import {
CheckCircle2Icon,
ChevronDownIcon,
ChevronUpIcon,
CopyIcon,
ExternalLinkIcon,
LoaderIcon,
MoveIcon,
SearchCheckIcon,
XCircleIcon,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Group, Panel, Separator } from 'react-resizable-panels'
import { onErrorDialog, reportError } from '../../lib/errors'
import { getFsInfo } from '../../lib/format'
// import { Document, Page, pdfjs } from 'react-pdf'
import { formatBytes } from '../../lib/format.ts'
import { notify } from '../../lib/notifications'
import { startCopy, startMove } from '../../lib/rclone/api'
import rclone from '../../lib/rclone/client'
import { openWindow } from '../../lib/window'
import { FileIcon } from '../components/navigator'
import { FilePanel, type FilePanelHandle } from '../components/navigator'
import type { Entry, SelectItem } from '../components/navigator/types'
export default function Browser() {
const leftPanelRef = useRef<FilePanelHandle>(null)
const rightPanelRef = useRef<FilePanelHandle>(null)
const [dropOperation, setDropOperation] = useState<{
items: SelectItem[]
destination: string
} | null>(null)
const [contextMenu, setContextMenu] = useState<{
entry: Entry
x: number
y: number
panelSide: 'left' | 'right'
} | null>(null)
const [trackedJobIds, setTrackedJobIds] = useState<Set<number>>(new Set())
const handleJobStarted = useCallback((jobId: number) => {
setTrackedJobIds((prev) => new Set([...prev, jobId]))
}, [])
const remotesQuery = useQuery({
queryKey: ['remotes', 'list', 'all'],
queryFn: async () => await rclone('/config/listremotes').then((r) => r?.remotes),
staleTime: 1000 * 60,
})
const remotes = remotesQuery.data ?? []
const firstRemote = remotes[0] ?? null
const rightPanelTarget = useMemo(() => {
const raw = new URLSearchParams(window.location.search).get('path')
if (!raw) return null
if (raw.includes(':/')) {
const [remote, ...rest] = raw.split(':/')
return { remote, path: rest.join('/') }
}
return { remote: 'UI_LOCAL_FS', path: raw }
}, [])
const handleDrop = useCallback(
(items: SelectItem[], destination: string, _sourceSide: 'left' | 'right') => {
setDropOperation({ items, destination })
},
[]
)
const handleOperationComplete = useCallback(() => {
leftPanelRef.current?.refresh()
rightPanelRef.current?.refresh()
}, [])
const handleDownload = useCallback(
async (entry: Entry) => {
const defaultName = entry.name
const savePath = await save({
title: `Save ${entry.isDir ? 'Folder' : 'File'}`,
defaultPath: defaultName,
})
if (!savePath) return
const srcInfo = getFsInfo(entry.fullPath)
const dstInfo = getFsInfo(savePath)
const srcFs = srcInfo.root
const srcRemote = srcInfo.filePath
const dstFs = dstInfo.root === ':local:' ? ':local:/' : dstInfo.root
const dstRemote = dstInfo.filePath
try {
const endpoint = entry.isDir ? '/sync/copy' : '/operations/copyfile'
const params = entry.isDir
? {
srcFs: `${srcFs}${srcRemote}/`,
dstFs: `${dstFs}${dstRemote}`,
createEmptySrcDirs: true,
}
: { srcFs, srcRemote, dstFs, dstRemote }
const result = await rclone(endpoint as any, {
params: { query: { ...params, _async: true } },
})
const jobId = result?.jobid
if (jobId) handleJobStarted(jobId)
} catch (error) {
await reportError(error, {
title: 'Error',
fallback: 'Download failed',
capture: false,
})
}
},
[handleJobStarted]
)
const closeContextMenu = useCallback(() => {
setContextMenu(null)
}, [])
const handleDelete = useCallback(async (entry: Entry) => {
const confirmed = await ask(`Are you sure you want to delete "${entry.name}"?`, {
title: 'Confirm Delete',
kind: 'warning',
})
if (!confirmed) return
try {
const source = entry.fullPath + (entry.isDir ? '/' : '')
const info = getFsInfo(source)
const endpoint = entry.isDir ? '/operations/purge' : '/operations/deletefile'
await rclone(endpoint as any, {
params: {
query: {
fs: info.root,
remote: info.filePath,
},
},
})
leftPanelRef.current?.refresh()
rightPanelRef.current?.refresh()
} catch (error) {
await reportError(error, {
title: 'Error',
fallback: 'Delete failed',
capture: false,
})
}
}, [])
const handleRename = useCallback(async (entry: Entry) => {
const newName = await invoke<string | null>('prompt', {
title: 'Rename',
message: `Enter a new name for "${entry.name}"`,
default: entry.name,
sensitive: false,
})
if (!newName || newName === entry.name) return
try {
const info = getFsInfo(entry.fullPath)
const parentDir = info.filePath.includes('/')
? info.filePath.slice(0, info.filePath.lastIndexOf('/') + 1)
: ''
const dstRemote = `${parentDir}${newName}`
if (entry.isDir) {
await rclone('/sync/move' as any, {
params: {
query: {
srcFs: `${info.root}${info.filePath}/`,
dstFs: `${info.root}${dstRemote}/`,
deleteEmptySrcDirs: true,
},
},
})
} else {
await rclone('/operations/movefile' as any, {
params: {
query: {
srcFs: info.root,
srcRemote: info.filePath,
dstFs: info.root,
dstRemote,
},
},
})
}
leftPanelRef.current?.refresh()
rightPanelRef.current?.refresh()
} catch (error) {
await reportError(error, {
title: 'Error',
fallback: 'Rename failed',
capture: false,
})
}
}, [])
const handleShare = useCallback(async (entry: Entry) => {
try {
const { root, filePath } = getFsInfo(entry.fullPath)
const result = await rclone('/operations/publiclink', {
params: {
query: {
fs: root,
remote: filePath,
},
},
})
if (result?.url) {
await writeText(result.url)
await notify({
title: 'Link Copied',
body: `Public link for "${entry.name}" copied to clipboard`,
})
}
} catch (error) {
await reportError(error, {
title: 'Share Error',
fallback: 'Failed to generate public link',
okLabel: 'OK',
capture: false,
})
}
}, [])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'r' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
leftPanelRef.current?.refresh()
rightPanelRef.current?.refresh()
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
useEffect(() => {
if (contextMenu) {
const handleClick = () => closeContextMenu()
window.addEventListener('click', handleClick)
return () => window.removeEventListener('click', handleClick)
}
}, [contextMenu, closeContextMenu])
return (
<div className="flex flex-col w-screen h-screen overflow-hidden">
<Group orientation="horizontal" className="flex-1">
<Panel defaultSize={50} minSize={25}>
<FilePanel
ref={leftPanelRef}
sidebarPosition="left"
initialRemote="UI_LOCAL_FS"
selectionMode="both"
allowFiles={true}
allowMultiple={true}
showPreviewColumn={true}
onDrop={(items, dest) => handleDrop(items, dest, 'left')}
onDownload={handleDownload}
onShare={handleShare}
onRename={handleRename}
onDelete={handleDelete}
allowedKeys={['REMOTES', 'LOCAL_FS', 'FAVORITES']}
isActive={true}
/>
</Panel>
<Separator className="w-1 transition-colors bg-divider hover:bg-primary-200 active:bg-primary-300" />
<Panel defaultSize={50} minSize={25}>
<FilePanel
ref={rightPanelRef}
sidebarPosition="right"
initialRemote={rightPanelTarget?.remote ?? firstRemote ?? 'UI_LOCAL_FS'}
initialPath={rightPanelTarget?.path}
selectionMode="both"
allowFiles={true}
allowMultiple={true}
showPreviewColumn={true}
onDrop={(items, dest) => handleDrop(items, dest, 'right')}
onDownload={handleDownload}
onShare={handleShare}
onRename={handleRename}
onDelete={handleDelete}
allowedKeys={['REMOTES', 'LOCAL_FS', 'FAVORITES']}
isActive={true}
/>
</Panel>
</Group>
<TransfersBar trackedJobIds={trackedJobIds} />
<OperationDialog
items={dropOperation?.items ?? null}
destination={dropOperation?.destination ?? null}
onClose={() => setDropOperation(null)}
onComplete={handleOperationComplete}
onJobStarted={handleJobStarted}
/>
{contextMenu && (
<div className="fixed z-50" style={{ top: contextMenu.y, left: contextMenu.x }}>
<Dropdown
isOpen={true}
onClose={closeContextMenu}
shadow={platform() === 'windows' ? 'none' : undefined}
>
<DropdownTrigger>
<span />
</DropdownTrigger>
<DropdownMenu
onAction={(key) => {
if (key === 'copy-path') {
navigator.clipboard.writeText(contextMenu.entry.fullPath)
}
closeContextMenu()
}}
>
<DropdownItem key="copy-path">Copy Path</DropdownItem>
</DropdownMenu>
</Dropdown>
</div>
)}
</div>
)
}
function TransfersBar({ trackedJobIds }: { trackedJobIds: Set<number> }) {
const [isExpanded, setIsExpanded] = useState(false)
const jobIds = Array.from(trackedJobIds)
const itemsQuery = useQuery({
queryKey: ['transfers', 'items', jobIds],
queryFn: async () => {
if (jobIds.length === 0) return { transferring: [], checking: [], transferred: [] }
const results = await Promise.all(
jobIds.map(async (jobId) => {
const [stats, transferredData] = await Promise.all([
rclone('/core/stats', { params: { query: { group: `job/${jobId}` } } }),
rclone('/core/transferred', {
params: { query: { group: `job/${jobId}` } },
}),
])
return {
jobId,
transferring: (stats?.transferring || []).map((t: any) => ({
...t,
jobId,
})),
checking: (stats?.checking || []).map((c: any) => ({ ...c, jobId })),
transferred: (transferredData?.transferred || []).map((t: any) => ({
...t,
jobId,
})),
}
})
)
return {
transferring: results.flatMap((r) => r.transferring),
checking: results.flatMap((r) => r.checking),
transferred: results.flatMap((r) => r.transferred),
}
},
refetchInterval: 1000,
enabled: jobIds.length > 0,
})
const transferring = itemsQuery.data?.transferring ?? []
const checking = itemsQuery.data?.checking ?? []
const transferred = itemsQuery.data?.transferred ?? []
const inProgressCount = transferring.length + checking.length
const hasTransfers = transferring.length > 0 || checking.length > 0 || transferred.length > 0
const toggleExpanded = useCallback(() => {
setIsExpanded((prev) => !prev)
}, [])
const handleOpenTransfers = useCallback(async () => {
await openWindow({ name: 'Transfers', url: '/transfers' })
}, [])
return (
<div className="border-t border-divider bg-content1">
<div
className="flex items-center justify-between h-10 px-4 cursor-pointer select-none hover:bg-content2"
onClick={toggleExpanded}
>
<div className="flex items-center gap-3">
<span className="text-sm font-medium">Transfers</span>
{inProgressCount > 0 && (
<span className="px-2 py-0.5 text-xs font-medium rounded-full bg-primary-100 text-primary-700">
{inProgressCount} active
</span>
)}
{!hasTransfers && (
<span className="text-sm text-default-400">No active transfers</span>
)}
</div>
<div className="flex items-center gap-2">
<Tooltip content="Open Transfers page" size="sm">
<Button
isIconOnly={true}
size="sm"
variant="light"
onPress={handleOpenTransfers}
onClick={(e) => e.stopPropagation()}
>
<ExternalLinkIcon className="size-4" />
</Button>
</Tooltip>
<Button
isIconOnly={true}
size="sm"
variant="light"
onPress={toggleExpanded}
onClick={(e) => e.stopPropagation()}
>
{isExpanded ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronUpIcon className="size-4" />
)}
</Button>
</div>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: '40vh', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden border-t border-divider"
>
<ScrollShadow className="h-[40vh] p-2">
{hasTransfers ? (
<div className="space-y-1">
{checking.map((item: any, idx: number) => (
<TransferItem
key={`checking-${item.name}-${idx}`}
item={item}
status="checking"
/>
))}
{transferring.map((item: any, idx: number) => (
<TransferItem
key={`transferring-${item.name}-${idx}`}
item={item}
status="transferring"
/>
))}
{transferred.map((item: any, idx: number) => (
<TransferItem
key={`transferred-${item.name}-${idx}`}
item={item}
status={item.error ? 'error' : 'done'}
/>
))}
</div>
) : (
<div className="flex items-center justify-center h-full text-sm text-default-400">
No transfers in progress
</div>
)}
</ScrollShadow>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
function TransferItem({
item,
status,
}: {
item: {
name?: string
size?: number
bytes?: number
percentage?: number
speed?: number
error?: string
}
status: 'transferring' | 'checking' | 'done' | 'error'
}) {
const fileName = item.name?.split('/').pop() || item.name || 'Unknown'
return (
<div className="flex items-center gap-3 p-2 rounded-lg hover:bg-content2">
<div className="shrink-0">
{status === 'error' ? (
<XCircleIcon className="text-danger size-4" />
) : status === 'done' ? (
<CheckCircle2Icon className="text-success size-4" />
) : status === 'checking' ? (
<SearchCheckIcon className="text-warning size-4 animate-pulse" />
) : (
<LoaderIcon className="text-primary size-4 animate-spin" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-2 mb-1">
<span className="text-sm truncate" title={item.name}>
{fileName}
</span>
<span className="text-xs shrink-0 text-default-500">
{status === 'error'
? 'Failed'
: status === 'done'
? formatBytes(item.size || 0)
: status === 'checking'
? 'Checking...'
: (item.size || 0) > 0
? `${formatBytes(item.bytes || 0)} / ${formatBytes(item.size || 0)}`
: '—'}
</span>
</div>
{(status === 'transferring' || status === 'checking') && (
<div className="flex items-center gap-2">
<Progress
value={item.percentage || 0}
size="sm"
color={status === 'checking' ? 'warning' : 'primary'}
className="flex-1"
aria-label="Transfer progress"
isIndeterminate={status === 'checking'}
/>
{(item.speed || 0) > 0 && (
<span className="text-xs shrink-0 text-default-400">
{formatBytes(item.speed || 0)}/s
</span>
)}
</div>
)}
{status === 'done' && item.name && (
<span className="text-xs truncate text-default-400" title={item.name}>
{item.name}
</span>
)}
{status === 'error' && item.error && (
<span className="text-xs truncate text-danger" title={item.error}>
{item.error}
</span>
)}
</div>
</div>
)
}
function OperationDialog({
items,
destination,
onClose,
onComplete,
onJobStarted,
}: {
items: SelectItem[] | null
destination: string | null
onClose: () => void
onComplete?: () => void
onJobStarted?: (jobId: number) => void
}) {
const [operation, setOperation] = useState<'copy' | 'move'>('copy')
const [overwrite, setOverwrite] = useState(false)
const copyMutation = useMutation({
mutationFn: async () => {
if (!items || !destination) throw new Error('Missing items or destination')
const sources = items.map((item) => item.path + (item.type === 'folder' ? '/' : ''))
const jobId = await startCopy({
sources,
destination,
options: {
copy: overwrite ? {} : { no_update_modtime: true },
config: {},
filter: {},
},
})
if (jobId) onJobStarted?.(jobId)
},
onSuccess: () => {
onComplete?.()
onClose()
},
onError: onErrorDialog('Error', 'Copy operation failed', { capture: false }),
})
const moveMutation = useMutation({
mutationFn: async () => {
if (!items || !destination) throw new Error('Missing items or destination')
const sources = items.map((item) => item.path + (item.type === 'folder' ? '/' : ''))
const jobId = await startMove({
sources,
destination,
options: {
move: overwrite ? {} : { no_update_modtime: true },
config: {},
filter: {},
},
})
if (jobId) onJobStarted?.(jobId)
},
onSuccess: () => {
onComplete?.()
onClose()
},
onError: onErrorDialog('Error', 'Move operation failed', { capture: false }),
})
const handleConfirm = useCallback(() => {
if (operation === 'copy') {
copyMutation.mutate()
} else {
moveMutation.mutate()
}
}, [operation, copyMutation, moveMutation])
const isLoading = copyMutation.isPending || moveMutation.isPending
const itemCount = items?.length ?? 0
const formatPath = (path: string) => {
if (path.includes(':/')) {
const [remote, ...rest] = path.split(':/')
const relativePath = rest.join('/')
const fileName = relativePath.split('/').pop() || relativePath
return { remote, fileName, isRemote: true }
}
const fileName = path.split('/').pop() || path
return { remote: 'Local', fileName, isRemote: false }
}
const destinationInfo = destination ? formatPath(destination) : null
return (
<Modal
isOpen={!!items && items.length > 0}
onClose={onClose}
size="lg"
hideCloseButton={isLoading}
>
<ModalContent>
<ModalHeader className="flex items-center gap-2">
{operation === 'copy' ? (
<CopyIcon className="size-5" />
) : (
<MoveIcon className="size-5" />
)}
<span>
{operation === 'copy' ? 'Copy' : 'Move'} {itemCount} item
{itemCount !== 1 ? 's' : ''}
</span>
</ModalHeader>
<ModalBody>
<div className="space-y-4">
<RadioGroup
label="Operation"
value={operation}
onValueChange={(val) => setOperation(val as 'copy' | 'move')}
orientation="vertical"
isDisabled={isLoading}
>
<Radio value="copy" description="Keep original files">
<div className="flex items-center gap-2">
<CopyIcon className="size-4" />
Copy
</div>
</Radio>
<Radio value="move" description="Delete after transfer">
<div className="flex items-center gap-2">
<MoveIcon className="size-4" />
Move
</div>
</Radio>
</RadioGroup>
<div className="p-3 rounded-lg bg-default-100">
<p className="mb-2 text-sm font-medium text-default-600">
Destination:
</p>
<div className="flex items-center gap-2">
<span className="px-2 py-1 text-xs font-medium rounded bg-primary-100 text-primary-700">
{destinationInfo?.remote}
</span>
<span className="text-sm truncate">
{destinationInfo?.fileName || '/'}
</span>
</div>
</div>
{itemCount > 1 && (
<div>
<p className="mb-2 text-sm font-medium text-default-600">
Items to transfer:
</p>
<ScrollShadow className="p-2 rounded-lg max-h-40 bg-default-50">
<ul className="space-y-1">
{items?.map((item) => {
const info = formatPath(item.path)
const mockEntry = {
key: item.path,
name: info.fileName,
isDir: item.type === 'folder',
fullPath: item.path,
} as Entry
return (
<li
key={item.path}
className="flex items-center gap-2 text-sm"
>
<FileIcon entry={mockEntry} size="sm" />
<span className="truncate">
{info.fileName}
</span>
{info.isRemote && (
<span className="px-1.5 py-0.5 text-xs rounded bg-default-200 text-default-600">
{info.remote}
</span>
)}
</li>
)
})}
</ul>
</ScrollShadow>
</div>
)}
<Checkbox
isSelected={overwrite}
onValueChange={setOverwrite}
isDisabled={isLoading}
size="sm"
>
Overwrite existing files
</Checkbox>
</div>
</ModalBody>
<ModalFooter>
<Button variant="flat" onPress={onClose} isDisabled={isLoading}>
Cancel
</Button>
<Button color="primary" onPress={handleConfirm} isLoading={isLoading}>
{operation === 'copy' ? 'Copy' : 'Move'}
</Button>
</ModalFooter>
</ModalContent>
</Modal>
)
}