refreshable transfers, #143

This commit is contained in:
FTCHD
2026-02-05 20:37:04 +03:00
parent a4fb9b45da
commit faded9d0ff
2 changed files with 175 additions and 10 deletions
+169 -9
View File
@@ -14,10 +14,11 @@ import {
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import { SearchCheckIcon, SquareIcon } from 'lucide-react'
import { useMemo } from 'react'
import { ExternalLinkIcon, RefreshCwIcon, SearchCheckIcon, SquareIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { formatBytes } from '../../lib/format'
import notify from '../../lib/notify'
import { startBatch } from '../../lib/rclone/api'
import rclone from '../../lib/rclone/client'
import type { JobItem } from '../../types/jobs'
@@ -25,12 +26,17 @@ export default function JobDetailsDrawer({
isOpen,
onClose,
selectedJob,
onSelectJob,
}: {
isOpen: boolean
onClose: () => void
selectedJob: JobItem
onSelectJob?: (job: JobItem) => void
}) {
const queryClient = useQueryClient()
const [retryStatus, setRetryStatus] = useState<
Map<string, { status: 'pending' | 'started' | 'error'; jobId?: number; error?: string }>
>(new Map())
const jobStatusQuery = useQuery({
queryKey: ['jobs', 'status', selectedJob.id],
@@ -118,6 +124,30 @@ export default function JobDetailsDrawer({
},
})
const retryMutation = useMutation({
mutationFn: async ({
key,
input,
}: { key: string; input: { _path: string } & Record<string, any> }) => {
setRetryStatus((prev) => new Map(prev).set(key, { status: 'pending' }))
const jobId = await startBatch([input])
return { key, jobId }
},
onSuccess: ({ key, jobId }) => {
setRetryStatus((prev) => new Map(prev).set(key, { status: 'started', jobId }))
queryClient.refetchQueries({ queryKey: ['transfers', 'list', 'all'] })
},
onError: (error, { key }) => {
console.error('Failed to retry transfer:', error)
setRetryStatus((prev) =>
new Map(prev).set(key, {
status: 'error',
error: error instanceof Error ? error.message : 'Unknown error',
})
)
},
})
const transferred = useMemo(
() => transferredQuery.data?.transferred || [],
[transferredQuery.data]
@@ -131,6 +161,17 @@ export default function JobDetailsDrawer({
[jobGroupStatsQuery.data]
)
const errorInputMap = useMemo(() => {
const map = new Map<string, { _path: string } & Record<string, any>>()
for (const err of otherErrors) {
const input = err.input as { srcRemote?: string; _path: string } & Record<string, any>
if (input?.srcRemote) {
map.set(input.srcRemote, input)
}
}
return map
}, [otherErrors])
return (
<Drawer isOpen={isOpen} placement="bottom" size="2xl" onClose={onClose}>
<DrawerContent
@@ -277,18 +318,137 @@ export default function JobDetailsDrawer({
) : null}
{transferred.map((item, itemIndex) => {
const itemKey = item.name || `item-${itemIndex}`
const itemRetryStatus = retryStatus.get(itemKey)
const inputForRetry = item.name
? errorInputMap.get(item.name)
: undefined
const canRetry =
item.error && inputForRetry && !itemRetryStatus?.status
return (
<div
key={item.name || itemIndex}
key={itemKey}
className="flex flex-row items-center justify-between gap-2 pb-2 border-b border-divider"
>
<p className="flex-1 line-clamp-1">{item.name}</p>
{item.error ? (
<p>{item.error}</p>
) : item.completed_at ? (
<p>{new Date(item.completed_at).toLocaleString()}</p>
) : null}
{/* <p>{item.what}</p> */}
{itemRetryStatus?.status === 'pending' && (
<div className="flex items-center gap-2 text-default-500">
<Spinner size="sm" />
<span className="text-sm">Retrying...</span>
</div>
)}
{itemRetryStatus?.status === 'started' &&
itemRetryStatus.jobId && (
<div className="flex items-center gap-2">
<Chip size="sm" color="success" variant="flat">
Job #{itemRetryStatus.jobId}
</Chip>
{onSelectJob && (
<Tooltip
content="View new job"
color="foreground"
>
<Button
isIconOnly={true}
size="sm"
variant="light"
onPress={() => {
onClose()
setTimeout(() => {
onSelectJob({
id: itemRetryStatus.jobId!,
type: 'active',
bytes: 0,
totalBytes: 0,
speed: 0,
done: false,
progress: 0,
hasError: false,
sources: [],
isChecking: false,
checkingCount: 0,
})
}, 300)
}}
>
<ExternalLinkIcon className="w-4 h-4" />
</Button>
</Tooltip>
)}
</div>
)}
{itemRetryStatus?.status === 'error' && (
<div className="flex items-center gap-2">
<span className="text-sm text-danger">
{itemRetryStatus.error || 'Retry failed'}
</span>
{inputForRetry && (
<Tooltip
content="Retry again"
color="foreground"
>
<Button
isIconOnly={true}
size="sm"
color="warning"
variant="flat"
onPress={() => {
setRetryStatus((prev) => {
const newMap = new Map(prev)
newMap.delete(itemKey)
return newMap
})
retryMutation.mutate({
key: itemKey,
input: inputForRetry,
})
}}
>
<RefreshCwIcon className="w-4 h-4" />
</Button>
</Tooltip>
)}
</div>
)}
{!itemRetryStatus && item.error && (
<div className="flex items-center gap-2">
<span className="text-sm text-danger line-clamp-1 max-w-64">
{item.error}
</span>
{canRetry && inputForRetry && (
<Tooltip
content="Retry this file"
color="foreground"
>
<Button
isIconOnly={true}
size="sm"
color="danger"
variant="flat"
onPress={() =>
retryMutation.mutate({
key: itemKey,
input: inputForRetry,
})
}
>
<RefreshCwIcon className="w-4 h-4" />
</Button>
</Tooltip>
)}
</div>
)}
{!itemRetryStatus && !item.error && item.completed_at && (
<p className="text-sm text-default-500">
{new Date(item.completed_at).toLocaleString()}
</p>
)}
</div>
)
})}
+6 -1
View File
@@ -120,7 +120,12 @@ export default function Transfers() {
/>
{selectedJob && (
<JobDetailsDrawer isOpen={isOpen} onClose={onClose} selectedJob={selectedJob} />
<JobDetailsDrawer
isOpen={isOpen}
onClose={onClose}
selectedJob={selectedJob}
onSelectJob={handleSelectJob}
/>
)}
</>
)