scheduler

This commit is contained in:
FTCHD
2026-07-11 21:25:10 +03:00
parent 5fe76f45f4
commit 234fa93e9a
46 changed files with 9498 additions and 646 deletions
+104
View File
@@ -0,0 +1,104 @@
import { Select, SelectItem } from '@heroui/react'
import { useQuery } from '@tanstack/react-query'
import { open } from '@tauri-apps/plugin-dialog'
import { FolderOpenIcon } from 'lucide-react'
import { useMemo } from 'react'
import { formatErrorMessage } from '../../lib/errors'
import { probeRcloneBinaryOrThrow } from '../../lib/rclone/common'
import { listDownloadedVersions } from '../../lib/rclone/versions'
export const APP_DEFAULT_BINARY = 'app-default'
const CUSTOM_BINARY = 'custom'
/**
* Picker for the rclone binary a scheduled task runs with: "App default", any downloaded
* managed version, or a custom path (probed before acceptance).
*/
export default function BinarySelect({
value,
onChange,
onError,
label = 'rclone binary',
}: {
value: string
onChange: (path: string) => void
onError?: (message: string) => void
label?: string
}) {
const versionsQuery = useQuery({
queryKey: ['rclone', 'downloaded-versions'],
queryFn: listDownloadedVersions,
})
const knownBinaryPaths = useMemo(
() => (versionsQuery.data ?? []).map((version) => version.path),
[versionsQuery.data]
)
const isCustomBinary = value !== APP_DEFAULT_BINARY && !knownBinaryPaths.includes(value)
const binaryOptions = useMemo(
() => [
{ key: APP_DEFAULT_BINARY, label: 'App default', description: undefined },
...(versionsQuery.data ?? []).map((version) => ({
key: version.path,
label: `v${version.version}`,
description: version.path,
})),
{
key: CUSTOM_BINARY,
label: isCustomBinary ? 'Custom binary' : 'Custom…',
description: isCustomBinary ? value : undefined,
},
],
[versionsQuery.data, isCustomBinary, value]
)
const pickCustomBinary = async () => {
const selected = await open({
title: 'Select rclone binary',
multiple: false,
directory: false,
})
if (!selected) {
return
}
try {
await probeRcloneBinaryOrThrow(selected)
} catch (error) {
onError?.(formatErrorMessage(error, 'The selected file is not a valid rclone binary'))
return
}
onChange(selected)
}
return (
<Select
label={label}
labelPlacement="outside"
selectedKeys={[isCustomBinary ? CUSTOM_BINARY : value]}
onSelectionChange={(keys) => {
const key = Array.from(keys)[0]
if (key === CUSTOM_BINARY) {
pickCustomBinary()
} else if (typeof key === 'string' && key) {
onChange(key)
}
}}
items={binaryOptions}
>
{(option) => (
<SelectItem
key={option.key}
description={option.description}
startContent={
option.key === CUSTOM_BINARY ? (
<FolderOpenIcon className="w-3.5 h-3.5" />
) : undefined
}
>
{option.label}
</SelectItem>
)}
</Select>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { Select, SelectItem } from '@heroui/react'
import { LockIcon } from 'lucide-react'
import type { ConfigFile } from '../../types/config'
/** Picker for the config file a scheduled task runs with (lock icon = encrypted config). */
export default function ConfigSelect({
configFiles,
value,
onChange,
label = 'Config file',
placeholder,
}: {
configFiles: ConfigFile[]
value: string | null
onChange: (id: string) => void
label?: string
placeholder?: string
}) {
return (
<Select
label={label}
labelPlacement="outside"
selectedKeys={value ? [value] : []}
onSelectionChange={(keys) => {
const id = Array.from(keys)[0]
if (typeof id === 'string') {
onChange(id)
}
}}
items={configFiles.filter((config) => !!config.id)}
placeholder={placeholder ?? 'Select a config'}
>
{(config) => (
<SelectItem
key={config.id!}
startContent={
config.isEncrypted ? (
<LockIcon className="w-3.5 h-3.5 text-warning" />
) : undefined
}
>
{config.label || config.id}
</SelectItem>
)}
</Select>
)
}
/** The scheduled runner cannot prompt for passwords — surface this before the task is saved. */
export function configPasswordMissing(configFiles: ConfigFile[], configId: string | null): boolean {
const config = configFiles.find((c) => c.id === configId)
return !!config?.isEncrypted && !config.pass && !config.passCommand
}
+13 -3
View File
@@ -7,6 +7,8 @@ import { startTransition, useCallback, useEffect, useMemo, useRef, useState } fr
interface CronEditorProps {
expression: string | null
onChange: (newExpression: string | null) => void
/** Platform-specific validation error from scheduler_validate_cron. */
error?: string | null
}
interface CronFieldProps {
@@ -18,7 +20,7 @@ interface CronFieldProps {
const DEFAULT_OPTIONS = ['*', '*/5', '*/10', '*/15', '*/30']
export default function CronEditor({ expression, onChange }: CronEditorProps) {
export default function CronEditor({ expression, onChange, error }: CronEditorProps) {
const [cronExpression, setCronExpression] = useState(expression)
const [minute, hour, dayOfMonth, month, dayOfWeek] = useMemo(
@@ -31,8 +33,7 @@ export default function CronEditor({ expression, onChange }: CronEditorProps) {
let description: string
try {
description = cronstrue.toString(cronExpression)
description +=
'. Tasks are triggered when the UI is running, if the active config is the same.'
description += '. Runs on a system schedule, even when the app is closed.'
} catch {
description = 'Invalid cron expression'
}
@@ -60,6 +61,14 @@ export default function CronEditor({ expression, onChange }: CronEditorProps) {
onChange(cronExpression)
}, [cronExpression, onChange])
// Re-sync when the parent changes `expression` out from under us (the edit drawer coercing a
// cleared field to '* * * * *', or an Advanced-section reset to null). Without this the
// control keeps its stale internal value — showing "not scheduled" while the parent saves a
// real cron. Echoes of our own emitted value set the same string and no-op.
useEffect(() => {
setCronExpression(expression)
}, [expression])
return (
<div className="flex flex-col w-full gap-2">
<Input
@@ -118,6 +127,7 @@ export default function CronEditor({ expression, onChange }: CronEditorProps) {
</div>
<div className="text-sm text-neutral-500">{readableDescription}</div>
{!!error && <div className="text-sm text-danger-500">{error}</div>}
</div>
)
}
+356 -68
View File
@@ -8,18 +8,33 @@ import {
DrawerContent,
DrawerFooter,
DrawerHeader,
Input,
ScrollShadow,
Switch,
Tab,
Tabs,
cn,
} from '@heroui/react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { platform } from '@tauri-apps/plugin-os'
import CronExpressionParser from 'cron-parser'
import { format } from 'date-fns'
import { format, formatDistance } from 'date-fns'
import { CalendarClockIcon } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { formatErrorMessage } from '../../lib/errors'
import { buildReadablePath } from '../../lib/format'
import { useNow } from '../../lib/hooks'
import {
DEFAULT_MAX_RUN_HOURS,
MAX_RUN_HOURS_LIMIT,
schedulerReadHistory,
schedulerReadLog,
updateScheduledTask as schedulerUpdateTask,
schedulerValidateCron,
} from '../../lib/scheduler'
import { useHostStore } from '../../store/host'
import type { ScheduledTask } from '../../types/schedules'
import BinarySelect from './BinarySelect'
import ConfigSelect, { configPasswordMissing as isConfigPasswordMissing } from './ConfigSelect'
import CronEditor from './CronEditor'
export default function ScheduleEditDrawer({
@@ -31,15 +46,39 @@ export default function ScheduleEditDrawer({
onClose: () => void
selectedTask: ScheduledTask
}) {
const updateScheduledTask = useHostStore((state) => state.updateScheduledTask)
const queryClient = useQueryClient()
const configFiles = useHostStore((state) => state.configFiles)
const [cronExpression, setCronExpression] = useState(selectedTask.cron)
const [configId, setConfigId] = useState(selectedTask.configId)
const [binaryPath, setBinaryPath] = useState(selectedTask.binaryPath)
const [isEnabled, setIsEnabled] = useState(selectedTask.isEnabled)
const [verboseLogging, setVerboseLogging] = useState(selectedTask.verboseLogging ?? false)
const [runMode, setRunMode] = useState<'system' | 'user'>(selectedTask.runMode ?? 'user')
const [maxRunHours, setMaxRunHours] = useState(
String(selectedTask.maxRunHours ?? DEFAULT_MAX_RUN_HOURS)
)
const [logView, setLogView] = useState<'runner' | 'daemon'>('runner')
const [saveError, setSaveError] = useState<string | null>(null)
useEffect(() => {
if (isOpen) {
setCronExpression(selectedTask.cron)
setConfigId(selectedTask.configId)
setBinaryPath(selectedTask.binaryPath)
setIsEnabled(selectedTask.isEnabled)
setVerboseLogging(selectedTask.verboseLogging ?? false)
setRunMode(selectedTask.runMode ?? 'user')
setMaxRunHours(String(selectedTask.maxRunHours ?? DEFAULT_MAX_RUN_HOURS))
setSaveError(null)
}
}, [isOpen, selectedTask.cron])
}, [isOpen, selectedTask])
const maxRunHoursNumber = Number(maxRunHours)
const maxRunHoursInvalid =
!Number.isInteger(maxRunHoursNumber) ||
maxRunHoursNumber < 1 ||
maxRunHoursNumber > MAX_RUN_HOURS_LIMIT
const source = useMemo(
() =>
@@ -56,32 +95,99 @@ export default function ScheduleEditDrawer({
// current time on every open (and kept fresh while open) — paused while closed.
const now = useNow(isOpen ? 30_000 : null)
const upcomingRuns = useMemo(() => {
try {
const parsed = CronExpressionParser.parse(cronExpression, {
currentDate: new Date(now),
})
const runs: Date[] = []
for (let i = 0; i < 5; i++) {
if (parsed.hasNext()) {
runs.push(parsed.next().toDate())
}
}
return runs
} catch {
return []
}
}, [cronExpression, now])
const cronValidation = useQuery({
queryKey: ['scheduler', 'validate-cron', cronExpression],
queryFn: () => schedulerValidateCron(cronExpression),
enabled: isOpen && !!cronExpression,
// The response carries the next-runs preview anchored at fetch time; without refetching,
// a frequent schedule (e.g. every minute) drains all 5 entries past `now` while the
// drawer sits open.
refetchInterval: 30_000,
})
const cronError =
cronValidation.data && !cronValidation.data.valid
? (cronValidation.data.error ?? 'Invalid cron expression')
: null
const hasChanges = useMemo(
() => cronExpression !== selectedTask.cron,
[cronExpression, selectedTask.cron]
const historyQuery = useQuery({
queryKey: ['scheduler', 'history', selectedTask.id],
queryFn: () => schedulerReadHistory(selectedTask.id, 10),
enabled: isOpen,
refetchInterval: 15_000,
})
const finishedRuns = useMemo(
() => (historyQuery.data ?? []).filter((line) => line.event === 'finished'),
[historyQuery.data]
)
const handleSave = useCallback(() => {
updateScheduledTask(selectedTask.id, { cron: cronExpression })
onClose()
}, [selectedTask.id, cronExpression, updateScheduledTask, onClose])
const selectedConfig = useMemo(
() => configFiles.find((config) => config.id === configId) ?? null,
[configFiles, configId]
)
const configMissing = !selectedConfig
const configPasswordMissing = isConfigPasswordMissing(configFiles, configId)
// From the validation query, i.e. computed in Rust by the runner's own cron matcher — a JS
// library here can (and did) predict fires real cron never performs (dom/dow star flag).
// The `now` filter keeps the list fresh between refetches of the 30s-anchored query.
const upcomingRuns = useMemo(
() =>
(cronValidation.data?.nextRuns ?? [])
.map((run) => new Date(run))
.filter((run) => run.getTime() > now),
[cronValidation.data, now]
)
const hasChanges = useMemo(
() =>
cronExpression !== selectedTask.cron ||
configId !== selectedTask.configId ||
binaryPath !== selectedTask.binaryPath ||
isEnabled !== selectedTask.isEnabled ||
verboseLogging !== (selectedTask.verboseLogging ?? false) ||
runMode !== (selectedTask.runMode ?? 'user') ||
maxRunHoursNumber !== (selectedTask.maxRunHours ?? DEFAULT_MAX_RUN_HOURS),
[
cronExpression,
configId,
binaryPath,
isEnabled,
verboseLogging,
runMode,
maxRunHoursNumber,
selectedTask,
]
)
const logQuery = useQuery({
queryKey: ['scheduler', 'log', selectedTask.id, logView],
queryFn: () => schedulerReadLog(selectedTask.id, logView),
enabled: isOpen,
refetchInterval: 5_000,
})
const saveMutation = useMutation({
mutationFn: async () => {
setSaveError(null)
await schedulerUpdateTask(selectedTask.id, {
cron: cronExpression,
configId,
binaryPath,
isEnabled,
verboseLogging,
runMode,
maxRunHours: maxRunHoursNumber,
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['scheduler'] })
onClose()
},
onError: (error) => {
setSaveError(formatErrorMessage(error, 'Failed to save the schedule'))
},
})
return (
<Drawer
@@ -126,14 +232,21 @@ export default function ScheduleEditDrawer({
<DrawerBody className="py-0">
<ScrollShadow size={30} visibility="top">
<div className="flex flex-col gap-6 pt-6 pb-10">
{selectedTask.lastRunError && (
{!!selectedTask.registrationError && (
<Alert
color="danger"
variant="faded"
title="Last Run Error"
title="Not registered with the system scheduler"
>
<pre className="text-sm break-all whitespace-pre-wrap">
{selectedTask.lastRunError}
{selectedTask.registrationError}
</pre>
</Alert>
)}
{!!saveError && (
<Alert color="danger" variant="faded" title="Save failed">
<pre className="text-sm break-all whitespace-pre-wrap">
{saveError}
</pre>
</Alert>
)}
@@ -159,46 +272,98 @@ export default function ScheduleEditDrawer({
</p>
</div>
)}
<div className="flex flex-col gap-1">
<p className="text-sm text-foreground-500">
Last Run
</p>
<p className="text-sm">
{selectedTask.lastRun
? format(
new Date(selectedTask.lastRun),
'PPpp'
)
: 'Never'}
</p>
</div>
<div className="flex flex-col gap-1">
<p className="text-sm text-foreground-500">
Status
</p>
<Chip
size="sm"
variant="flat"
color={
selectedTask.isRunning
? 'success'
: selectedTask.isEnabled
? 'primary'
: 'warning'
}
>
{selectedTask.isRunning
? 'Running'
: selectedTask.isEnabled
? 'Enabled'
: 'Paused'}
</Chip>
</div>
</div>
</div>
<Divider />
<div className="flex flex-col gap-3">
<h3 className="text-lg font-medium">Execution</h3>
<div className="grid grid-cols-2 gap-4">
<ConfigSelect
configFiles={configFiles}
value={configId}
onChange={setConfigId}
placeholder={
configMissing
? 'Config no longer exists'
: undefined
}
/>
<BinarySelect
value={binaryPath}
onChange={(path) => {
setSaveError(null)
setBinaryPath(path)
}}
onError={setSaveError}
/>
</div>
{configMissing && (
<Alert color="danger" variant="faded" title="">
The config this task used no longer exists — pick
another one.
</Alert>
)}
{configPasswordMissing && (
<Alert
color="warning"
variant="faded"
title="Encrypted config without a saved password"
>
This config is encrypted and has no saved password
or password command. The scheduled runner cannot
prompt for it, so runs will fail until you save the
password in Settings → Config.
</Alert>
)}
<Switch
size="sm"
color="primary"
isSelected={isEnabled}
onValueChange={setIsEnabled}
data-focus-visible="false"
>
Enabled
</Switch>
<div className="flex flex-col gap-1">
<span className="text-small">Run mode</span>
<Tabs
size="sm"
selectedKey={runMode}
onSelectionChange={(key) =>
setRunMode(key as 'system' | 'user')
}
data-focus-visible="false"
>
<Tab key="user" title="User" />
<Tab key="system" title="System" />
</Tabs>
<span className="text-tiny text-default-400">
{runMode === 'user'
? 'Runs only while you are logged in, inside your session — OS keychain passwords and session-mounted drives work; fires while logged out are skipped. On macOS it runs as Rclone UI, so protected folders work once you grant the app access.'
: 'Runs even while logged out, but outside your login session — no OS keychain or session-mounted drives, and protected folders on macOS need Full Disk Access for cron.'}
</span>
</div>
<Switch
size="sm"
color="primary"
isSelected={verboseLogging}
onValueChange={setVerboseLogging}
data-focus-visible="false"
>
<div className="flex flex-col">
<span className="text-small">Verbose logging</span>
<span className="text-tiny text-default-400">
Log individual transfers to the rclone log
(grows faster)
</span>
</div>
</Switch>
</div>
<Divider />
<div className="flex flex-col gap-3">
<h3 className="flex items-center gap-2 text-lg font-medium">
<CalendarClockIcon className="w-5 h-5" />
@@ -246,8 +411,125 @@ export default function ScheduleEditDrawer({
onChange={(expr) =>
setCronExpression(expr || '* * * * *')
}
error={cronError}
/>
</div>
<Divider />
<div className="flex flex-col gap-3">
<h3 className="text-lg font-medium">Advanced</h3>
<Input
type="number"
label="Max run time (hours)"
labelPlacement="outside"
min={1}
max={MAX_RUN_HOURS_LIMIT}
value={maxRunHours}
onValueChange={setMaxRunHours}
isInvalid={maxRunHoursInvalid}
errorMessage={`Enter a whole number of hours between 1 and ${MAX_RUN_HOURS_LIMIT}`}
description="A run still going after this long is stopped and marked failed."
className="max-w-64"
data-focus-visible="false"
/>
</div>
<Divider />
<div className="flex flex-col gap-3">
<h3 className="text-lg font-medium">Run History</h3>
{finishedRuns.length > 0 ? (
<div className="flex flex-col gap-2">
{finishedRuns.map((run) =>
run.event === 'finished' ? (
<div
key={run.runId}
className="flex items-center gap-3 text-sm"
>
<Chip
size="sm"
variant="flat"
color={
run.success
? 'success'
: 'danger'
}
>
{run.success ? 'OK' : 'Failed'}
</Chip>
<span>
{formatDistance(
new Date(run.ts),
new Date(now),
{ addSuffix: true }
)}
</span>
<span className="text-foreground-500">
{Math.round(run.durationMs / 1000)}s
</span>
{!!run.error && (
<span className="text-danger-500 line-clamp-1">
{run.error}
</span>
)}
</div>
) : null
)}
</div>
) : (
<p className="text-sm text-foreground-500">
This task hasn't run yet.
</p>
)}
</div>
<Divider />
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h3 className="text-lg font-medium">Logs</h3>
<div className="flex gap-1">
<Button
size="sm"
variant={
logView === 'runner' ? 'solid' : 'light'
}
color={
logView === 'runner' ? 'primary' : 'default'
}
onPress={() => setLogView('runner')}
data-focus-visible="false"
>
Runner
</Button>
<Button
size="sm"
variant={
logView === 'daemon' ? 'solid' : 'light'
}
color={
logView === 'daemon' ? 'primary' : 'default'
}
onPress={() => setLogView('daemon')}
data-focus-visible="false"
>
rclone
</Button>
</div>
</div>
{logQuery.data?.truncated && (
<p className="text-tiny text-default-400">
Showing the last 64 KB — older lines are on disk.
</p>
)}
<pre className="p-3 overflow-auto font-mono whitespace-pre-wrap rounded-medium bg-content2 text-tiny max-h-64">
{logQuery.data?.content ||
(logView === 'runner'
? 'No runner output yet.'
: 'No rclone output yet.')}
</pre>
</div>
</div>
</ScrollShadow>
</DrawerBody>
@@ -262,8 +544,14 @@ export default function ScheduleEditDrawer({
</Button>
<Button
color="primary"
isDisabled={!hasChanges}
onPress={handleSave}
isDisabled={
!hasChanges ||
!!cronError ||
configMissing ||
maxRunHoursInvalid
}
isLoading={saveMutation.isPending}
onPress={() => saveMutation.mutate()}
data-focus-visible="false"
>
SAVE CHANGES
@@ -0,0 +1,139 @@
import { Button, cn } from '@heroui/react'
import { useQuery } from '@tanstack/react-query'
import { ChevronDownIcon, SlidersHorizontalIcon } from 'lucide-react'
import { useCallback, useState } from 'react'
import { LOCAL_HOST_ID } from '../../../lib/hosts'
import { schedulerValidateCron, useSchedulerSupported } from '../../../lib/scheduler'
import { useHostStore } from '../../../store/host'
import { usePersistedStore } from '../../../store/persisted'
import BinarySelect, { APP_DEFAULT_BINARY } from '../BinarySelect'
import ConfigSelect, { configPasswordMissing } from '../ConfigSelect'
import CronEditor from '../CronEditor'
export interface AdvancedSchedule {
cronExpression: string | null
setCronExpression: (expr: string | null) => void
binaryPath: string
setBinaryPath: (path: string) => void
/** null = use the active config at creation time. */
configId: string | null
setConfigId: (id: string) => void
reset: () => void
}
/** Page-lifted state for the Advanced section: the schedule the page's create flow reads. */
export function useAdvancedSchedule(): AdvancedSchedule {
const [cronExpression, setCronExpression] = useState<string | null>(null)
const [binaryPath, setBinaryPath] = useState<string>(APP_DEFAULT_BINARY)
const [configId, setConfigId] = useState<string | null>(null)
const reset = useCallback(() => {
setCronExpression(null)
setBinaryPath(APP_DEFAULT_BINARY)
setConfigId(null)
}, [])
return {
cronExpression,
setCronExpression,
binaryPath,
setBinaryPath,
configId,
setConfigId,
reset,
}
}
/**
* Collapsible "Advanced" block rendered under the path inputs on the operation pages: cron
* schedule plus the binary and config the scheduled task will run with. These apply to the
* SCHEDULED task only (live runs go through the app's shared daemon), so the section hides
* entirely where scheduling can't work (sandboxed installs, remote hosts).
*/
export default function AdvancedScheduleSection({ advanced }: { advanced: AdvancedSchedule }) {
const [expanded, setExpanded] = useState(false)
const [pickerError, setPickerError] = useState<string | null>(null)
const currentHostId = usePersistedStore((state) => state.currentHostId) ?? LOCAL_HOST_ID
const supportQuery = useSchedulerSupported()
const configFiles = useHostStore((state) => state.configFiles)
const activeConfigId = useHostStore((state) => state.activeConfigId)
const cronValidation = useQuery({
queryKey: ['scheduler', 'validate-cron', advanced.cronExpression],
queryFn: () => schedulerValidateCron(advanced.cronExpression ?? ''),
enabled: expanded && !!advanced.cronExpression,
})
const cronError =
advanced.cronExpression && cronValidation.data && !cronValidation.data.valid
? (cronValidation.data.error ?? 'Invalid cron expression')
: null
if (currentHostId !== LOCAL_HOST_ID || !(supportQuery.data?.supported ?? false)) {
return null
}
const effectiveConfigId = advanced.configId ?? activeConfigId
const passwordMissing = configPasswordMissing(configFiles, effectiveConfigId)
const hasSchedule = !!advanced.cronExpression
return (
<div className="flex flex-col gap-4 px-4">
<Button
variant="light"
size="sm"
className="self-start"
startContent={<SlidersHorizontalIcon className="w-4 h-4" />}
endContent={
<ChevronDownIcon
className={cn('w-4 h-4 transition-transform', expanded && 'rotate-180')}
/>
}
onPress={() => setExpanded(!expanded)}
data-focus-visible="false"
>
Advanced{hasSchedule ? ' — scheduled' : ''}
</Button>
{expanded && (
<div className="flex flex-col gap-4 p-4 border rounded-medium border-divider bg-content2/50">
<div className="flex flex-col gap-2">
<p className="text-sm font-semibold uppercase text-default-500">Schedule</p>
<CronEditor
expression={advanced.cronExpression}
onChange={advanced.setCronExpression}
error={cronError}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<ConfigSelect
configFiles={configFiles}
value={effectiveConfigId}
onChange={advanced.setConfigId}
/>
<BinarySelect
value={advanced.binaryPath}
onChange={(path) => {
setPickerError(null)
advanced.setBinaryPath(path)
}}
onError={setPickerError}
/>
</div>
<p className="text-tiny text-default-400">
The config and binary apply to the scheduled task; immediate runs use the
app's active config and binary.
</p>
{!!pickerError && <p className="text-sm text-danger-500">{pickerError}</p>}
{passwordMissing && (
<p className="text-sm text-warning-600">
This config is encrypted with no saved password — scheduled runs will
fail until you save it in Settings → Config.
</p>
)}
</div>
)}
</div>
)
}
+30 -10
View File
@@ -11,7 +11,10 @@ import { platform } from '@tauri-apps/plugin-os'
import { AnimatePresence, motion } from 'framer-motion'
import { ClockIcon, EyeIcon } from 'lucide-react'
import { type ComponentProps, type ReactNode, useCallback, useMemo } from 'react'
import { LOCAL_HOST_ID } from '../../../lib/hosts'
import { useSchedulerSupported } from '../../../lib/scheduler'
import { openWindow } from '../../../lib/window'
import { usePersistedStore } from '../../../store/persisted'
import type { Template } from '../../../types/template'
import CommandInfoButton from '../CommandInfoButton'
import CommandsDropdown from '../CommandsDropdown'
@@ -72,6 +75,20 @@ export default function OperationFooter({
}) {
const dropdownShadow = useMemo(() => (platform() === 'windows' ? 'none' : undefined), [])
// Scheduling is OS-native and local-host-only; hide the affordance where it can't work
// (sandboxed installs, remote hosts).
const currentHostId = usePersistedStore((state) => state.currentHostId) ?? LOCAL_HOST_ID
const supportQuery = useSchedulerSupported()
// Treat unresolved support as unavailable (matches AdvancedScheduleSection and Schedules.tsx)
// — otherwise the Schedule button is enabled while the only cron-entry UI is still hidden.
const schedulingAvailable =
currentHostId === LOCAL_HOST_ID && (supportQuery.data?.supported ?? false)
const scheduleTooltip = schedulingAvailable
? 'Schedule task'
: currentHostId !== LOCAL_HOST_ID
? 'Scheduling is only available on your local machine'
: (supportQuery.data?.reason ?? 'Scheduling is not available on this system')
const handleStartPress = useCallback(() => {
setTimeout(() => onStart(), 100)
}, [onStart])
@@ -194,16 +211,19 @@ export default function OperationFooter({
</Button>
</Tooltip>
) : null}
<Tooltip content="Schedule task" placement="top" size="lg" color="foreground">
<Button
size="lg"
type="button"
color="primary"
isIconOnly={true}
onPress={handleSchedulePress}
>
<ClockIcon className="size-6" />
</Button>
<Tooltip content={scheduleTooltip} placement="top" size="lg" color="foreground">
<div>
<Button
size="lg"
type="button"
color="primary"
isIconOnly={true}
isDisabled={!schedulingAvailable}
onPress={handleSchedulePress}
>
<ClockIcon className="size-6" />
</Button>
</div>
</Tooltip>
<CommandInfoButton content={helpContent} />
<CommandsDropdown currentCommand={operation} />
@@ -1,6 +1,5 @@
import { Accordion, AccordionItem, Avatar } from '@heroui/react'
import {
ClockIcon,
CopyIcon,
DiamondPercentIcon,
FilterIcon,
@@ -14,7 +13,7 @@ import ShowMoreOptionsBanner from '../ShowMoreOptionsBanner'
// Avatar/indicator/title per option category — exactly what each page's accordion rendered.
export const CATEGORY_META: Record<
'copy' | 'sync' | 'move' | 'bisync' | 'filters' | 'cron' | 'config' | 'remotes',
'copy' | 'sync' | 'move' | 'bisync' | 'filters' | 'config' | 'remotes',
{
title: string
icon: ComponentType<{ className?: string }>
@@ -33,7 +32,6 @@ export const CATEGORY_META: Record<
avatarIconClassName: 'text-success-foreground',
},
filters: { title: 'Filters', icon: FilterIcon, avatarColor: 'danger' },
cron: { title: 'Cron', icon: ClockIcon, avatarColor: 'warning' },
config: { title: 'Config', icon: WrenchIcon, avatarColor: 'default' },
remotes: { title: 'Remotes', icon: ServerIcon, avatarClassName: 'bg-fuchsia-500' },
}
@@ -50,7 +48,7 @@ export interface OptionsAccordionItemDef {
/**
* The option-group accordion shared by the operation pages: item scaffolding (Avatar,
* indicator, title) comes from CATEGORY_META; each item's content (OptionsSection /
* CronEditor / RemoteOptionsSection) stays page-supplied. `banner` wraps the accordion in the
* RemoteOptionsSection) stays page-supplied. `banner` wraps the accordion in the
* relative div with the ShowMoreOptionsBanner (Copy/Sync/Move); Bisync/Delete/Purge omit it.
*/
export default function OptionsAccordion({
+14 -13
View File
@@ -1,25 +1,30 @@
import { useMutation } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core'
import cronstrue from 'cronstrue'
import { onErrorDialog } from '../../../lib/errors'
import notify from '../../../lib/notify'
import { useHostStore } from '../../../store/host'
import { notify } from '../../../lib/notifications'
import { createScheduledTask } from '../../../lib/scheduler'
import type { ScheduledTask } from '../../../types/schedules'
/**
* The schedule mutation shared by the operation pages: page-specific validation (path checks,
* the Copy/Move multi-source license gate) → cron validation → native name prompt →
* addScheduledTask with the page-built args. `buildArgs` must return the EXACT persisted args
* shape the page's start function takes — main.ts replays these verbatim.
* the Copy/Move multi-source license gate) → per-platform cron validation → native name prompt →
* createScheduledTask, which persists the task and registers it with the OS scheduler. The
* headless runner replays the pre-serialized requests built from `buildArgs()` output.
*/
export function useScheduleTask<O extends ScheduledTask['operation']>({
operation,
cronExpression,
configId,
binaryPath,
buildArgs,
validate,
}: {
operation: O
cronExpression: string | null
/** From the page's Advanced section; omitted/null = active config. */
configId?: string | null
/** From the page's Advanced section; omitted = 'app-default'. */
binaryPath?: string
buildArgs: () => Extract<ScheduledTask, { operation: O }>['args']
validate?: () => void
}) {
@@ -31,12 +36,6 @@ export function useScheduleTask<O extends ScheduledTask['operation']>({
throw new Error('Please enter a cron expression')
}
try {
cronstrue.toString(cronExpression)
} catch {
throw new Error('Invalid cron expression')
}
const name = await invoke<string | null>('prompt', {
title: 'Schedule Name',
message: 'Enter a name for this schedule',
@@ -47,11 +46,13 @@ export function useScheduleTask<O extends ScheduledTask['operation']>({
throw new Error('Schedule name is required')
}
useHostStore.getState().addScheduledTask({
await createScheduledTask({
name,
operation,
cron: cronExpression,
args: buildArgs(),
configId: configId ?? undefined,
binaryPath,
})
},
onSuccess: async () => {
+12 -12
View File
@@ -8,12 +8,14 @@ import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks'
import { startBisync } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection'
import { PathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection'
import AdvancedScheduleSection, {
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
@@ -101,7 +103,7 @@ export default function Bisync() {
const [outerBisyncOptions, setOuterBisyncOptions] = useState<Record<string, boolean>>({})
const [cronExpression, setCronExpression] = useState<string | null>(null)
const advanced = useAdvancedSchedule()
const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest])
@@ -139,7 +141,7 @@ export default function Bisync() {
return startBisync(buildStartArgs())
},
onSuccess: () => {
if (cronExpression) {
if (advanced.cronExpression) {
scheduleTaskMutation.mutate()
}
},
@@ -151,7 +153,9 @@ export default function Bisync() {
const scheduleTaskMutation = useScheduleTask({
operation: 'bisync',
cronExpression,
cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => {
if (!source || !dest) {
throw new Error('Please select both a source and destination path')
@@ -166,9 +170,9 @@ export default function Bisync() {
if (!dest) return 'Please select a destination path'
if (source === dest) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE BISYNC'
if (advanced.cronExpression) return 'START AND SCHEDULE BISYNC'
return 'START BISYNC'
}, [startBisyncMutation.isPending, source, dest, jsonError, cronExpression])
}, [startBisyncMutation.isPending, source, dest, jsonError, advanced.cronExpression])
const buttonIcon = useMemo(() => {
if (startBisyncMutation.isPending) return
@@ -316,11 +320,6 @@ export default function Bisync() {
/>
),
},
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
{
key: 'config',
category: 'config',
@@ -368,7 +367,6 @@ export default function Bisync() {
copyFlags,
filterGroup,
filterFlags,
cronExpression,
configGroup,
configFlags,
selectedRemotes,
@@ -426,6 +424,8 @@ export default function Bisync() {
setDestPath={setDest}
/>
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion items={accordionItems} />
</OperationWindowContent>
+17 -17
View File
@@ -8,12 +8,14 @@ import { useFlags } from '../../lib/hooks'
import { startCopy, startDryRun } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { usePersistedStore } from '../../store/persisted'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection'
import { MultiPathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection'
import AdvancedScheduleSection, {
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
@@ -40,7 +42,7 @@ Expand the accordion sections to customize your copy operation. Tap any chip on
• Filters — Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age).
• Cron — Schedule this copy to run automatically at set intervals. The schedule only triggers while the app is running.
• Cron — Schedule this copy to run automatically at set intervals. It runs on a system schedule, even when the app is closed.
• Config — Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes.
@@ -84,7 +86,7 @@ export default function Copy() {
const filterGroup = optionGroups.filter
const configGroup = optionGroups.config
const [cronExpression, setCronExpression] = useState<string | null>(null)
const advanced = useAdvancedSchedule()
const selectedRemotes = useMemo(
() => [...(sources || []), dest].filter(Boolean),
@@ -111,7 +113,7 @@ export default function Copy() {
return startCopy(buildArgs())
},
onSuccess: () => {
if (cronExpression) {
if (advanced.cronExpression) {
scheduleTaskMutation.mutate()
}
},
@@ -123,7 +125,9 @@ export default function Copy() {
const scheduleTaskMutation = useScheduleTask({
operation: 'copy',
cronExpression,
cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
@@ -160,9 +164,9 @@ export default function Copy() {
if (!dest) return 'Please select a destination path'
if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE COPY'
if (advanced.cronExpression) return 'START AND SCHEDULE COPY'
return 'START COPY'
}, [startCopyMutation.isPending, sources, dest, jsonError, cronExpression])
}, [startCopyMutation.isPending, sources, dest, jsonError, advanced.cronExpression])
const buttonIcon = useMemo(() => {
if (startCopyMutation.isPending) return
@@ -204,11 +208,6 @@ export default function Copy() {
/>
),
},
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
{
key: 'config',
category: 'config',
@@ -258,7 +257,6 @@ export default function Copy() {
copyFlags,
filterFlags,
configFlags,
cronExpression,
selectedRemotes,
]
)
@@ -284,21 +282,21 @@ export default function Copy() {
const handleResetOptions = useCallback(() => {
startTransition(() => {
resetJson()
setCronExpression(null)
advanced.reset()
startCopyMutation.reset()
})
}, [resetJson, startCopyMutation.reset])
}, [advanced.reset, resetJson, startCopyMutation.reset])
const handleResetAll = useCallback(() => {
startTransition(() => {
resetJson()
resetLocks()
setCronExpression(null)
advanced.reset()
setSources(undefined)
setDest(undefined)
startCopyMutation.reset()
})
}, [resetJson, resetLocks, startCopyMutation.reset])
}, [advanced.reset, resetJson, resetLocks, startCopyMutation.reset])
useEffect(() => {
console.log('[Copy] remoteOptions', remotesGroup.options)
@@ -317,6 +315,8 @@ export default function Copy() {
setDestPath={setDest}
/>
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion banner={true} items={accordionItems} />
</OperationWindowContent>
+19 -18
View File
@@ -7,14 +7,16 @@ import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags'
import { getRemoteName } from '../../lib/format'
import { useFlags, useRemoteConfig } from '../../lib/hooks'
import notify from '../../lib/notify'
import { notify } from '../../lib/notifications'
import { startDelete, startDryRun } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS, SUPPORTS_PURGE } from '../../lib/rclone/constants'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection'
import { PathField } from '../components/PathFinder'
import AdvancedScheduleSection, {
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
@@ -43,7 +45,7 @@ Expand the accordion sections to customize your delete operation. Tap any chip o
• Config — Performance tuning: parallel checkers, and other global rclone settings.
• Cron — Schedule this delete to run automatically at set intervals. Useful for automated cleanup tasks. The schedule only triggers while the app is running.
• Cron — Schedule this delete to run automatically at set intervals. Useful for automated cleanup tasks. It runs on a system schedule, even when the app is closed.
3. USE TEMPLATES (Optional)
Tap the folder icon in the bottom bar to load or save option presets. Templates let you quickly apply common filter configurations for recurring cleanup tasks.
@@ -59,7 +61,7 @@ export default function Delete() {
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
)
const [cronExpression, setCronExpression] = useState<string | null>(null)
const advanced = useAdvancedSchedule()
const {
jsonError,
@@ -108,7 +110,7 @@ export default function Delete() {
title: 'Success',
body: 'Delete task started',
})
if (cronExpression) {
if (advanced.cronExpression) {
scheduleTaskMutation.mutate()
}
},
@@ -119,7 +121,9 @@ export default function Delete() {
const scheduleTaskMutation = useScheduleTask({
operation: 'delete',
cronExpression,
cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => {
if (!sourceFs) {
throw new Error('Please select a source path to delete')
@@ -147,9 +151,9 @@ export default function Delete() {
if (startDeleteMutation.isPending) return 'STARTING...'
if (!sourceFs || sourceFs.length === 0) return 'Please select a source path'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE DELETE'
if (advanced.cronExpression) return 'START AND SCHEDULE DELETE'
return 'START DELETE'
}, [startDeleteMutation.isPending, sourceFs, jsonError, cronExpression])
}, [startDeleteMutation.isPending, sourceFs, jsonError, advanced.cronExpression])
const buttonIcon = useMemo(() => {
if (startDeleteMutation.isPending) return
@@ -190,13 +194,8 @@ export default function Delete() {
/>
),
},
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
],
[filterGroup, configGroup, globalFlags, filterFlags, configFlags, cronExpression]
[filterGroup, configGroup, globalFlags, filterFlags, configFlags]
)
const handleStart = useCallback(
@@ -222,20 +221,20 @@ export default function Delete() {
const handleResetOptions = useCallback(() => {
startTransition(() => {
resetJson()
setCronExpression(null)
advanced.reset()
startDeleteMutation.reset()
})
}, [resetJson, startDeleteMutation.reset])
}, [advanced.reset, resetJson, startDeleteMutation.reset])
const handleResetAll = useCallback(() => {
startTransition(() => {
resetJson()
resetLocks()
setCronExpression(null)
advanced.reset()
setSourceFs(undefined)
startDeleteMutation.reset()
})
}, [resetJson, resetLocks, startDeleteMutation.reset])
}, [advanced.reset, resetJson, resetLocks, startDeleteMutation.reset])
return (
<div className="flex flex-col h-screen gap-10">
@@ -264,6 +263,8 @@ export default function Delete() {
</Alert>
)}
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion items={accordionItems} />
</OperationWindowContent>
+17 -17
View File
@@ -8,12 +8,14 @@ import { useFlags } from '../../lib/hooks'
import { startDryRun, startMove } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { usePersistedStore } from '../../store/persisted'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection'
import { MultiPathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection'
import AdvancedScheduleSection, {
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
@@ -44,7 +46,7 @@ Expand the accordion sections to customize your move operation. Tap any chip on
• Filters — Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age).
• Cron — Schedule this move to run automatically at set intervals. The schedule only triggers while the app is running.
• Cron — Schedule this move to run automatically at set intervals. It runs on a system schedule, even when the app is closed.
• Config — Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes.
@@ -88,7 +90,7 @@ export default function Move() {
const filterGroup = optionGroups.filter
const configGroup = optionGroups.config
const [cronExpression, setCronExpression] = useState<string | null>(null)
const advanced = useAdvancedSchedule()
const selectedRemotes = useMemo(
() => [...(sources || []), dest].filter(Boolean),
@@ -115,7 +117,7 @@ export default function Move() {
return startMove(buildArgs())
},
onSuccess: () => {
if (cronExpression) {
if (advanced.cronExpression) {
scheduleTaskMutation.mutate()
}
},
@@ -127,7 +129,9 @@ export default function Move() {
const scheduleTaskMutation = useScheduleTask({
operation: 'move',
cronExpression,
cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
@@ -164,9 +168,9 @@ export default function Move() {
if (!dest) return 'Please select a destination path'
if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE MOVE'
if (advanced.cronExpression) return 'START AND SCHEDULE MOVE'
return 'START MOVE'
}, [startMoveMutation.isPending, sources, dest, jsonError, cronExpression])
}, [startMoveMutation.isPending, sources, dest, jsonError, advanced.cronExpression])
const buttonIcon = useMemo(() => {
if (startMoveMutation.isPending) return
@@ -208,11 +212,6 @@ export default function Move() {
/>
),
},
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
{
key: 'config',
category: 'config',
@@ -262,7 +261,6 @@ export default function Move() {
filterFlags,
configFlags,
copyFlags,
cronExpression,
selectedRemotes,
]
)
@@ -288,21 +286,21 @@ export default function Move() {
const handleResetOptions = useCallback(() => {
startTransition(() => {
resetJson()
setCronExpression(null)
advanced.reset()
startMoveMutation.reset()
})
}, [resetJson, startMoveMutation.reset])
}, [advanced.reset, resetJson, startMoveMutation.reset])
const handleResetAll = useCallback(() => {
startTransition(() => {
resetJson()
resetLocks()
setCronExpression(null)
advanced.reset()
setSources(undefined)
setDest(undefined)
startMoveMutation.reset()
})
}, [resetJson, resetLocks, startMoveMutation.reset])
}, [advanced.reset, resetJson, resetLocks, startMoveMutation.reset])
return (
<div className="flex flex-col h-screen gap-10">
@@ -316,6 +314,8 @@ export default function Move() {
setDestPath={setDest}
/>
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion banner={true} items={accordionItems} />
</OperationWindowContent>
+19 -18
View File
@@ -7,11 +7,13 @@ import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks'
import { startPurge } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection'
import { PathField } from '../components/PathFinder'
import AdvancedScheduleSection, {
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
@@ -21,7 +23,7 @@ import { useScheduleTask } from '../components/operation/useScheduleTask'
const PATH_ALLOWED_KEYS: ('LOCAL_FS' | 'FAVORITES' | 'REMOTES')[] = ['REMOTES', 'FAVORITES']
const DEFAULT_EXPANDED_KEYS = ['config', 'cron']
const DEFAULT_EXPANDED_KEYS = ['config']
const HELP_CONTENT = `Removes a path and ALL of its contents.
@@ -41,7 +43,7 @@ Expand the accordion sections to customize your purge operation. Tap any chip on
• Config — The "checkers" option controls concurrency for backends that don't support server-side purge. Other global rclone settings are also available here.
• Cron — Schedule this purge to run automatically at set intervals. Useful for automated cleanup of temporary folders. The schedule only triggers while the app is running.
• Cron — Schedule this purge to run automatically at set intervals. Useful for automated cleanup of temporary folders. It runs on a system schedule, even when the app is closed.
3. USE TEMPLATES (Optional)
Tap the folder icon in the bottom bar to load or save option presets.
@@ -57,7 +59,7 @@ export default function Purge() {
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
)
const [cronExpression, setCronExpression] = useState<string | null>(null)
const advanced = useAdvancedSchedule()
const {
jsonError,
@@ -88,7 +90,7 @@ export default function Purge() {
return startPurge(buildArgs())
},
onSuccess: async () => {
if (cronExpression) {
if (advanced.cronExpression) {
scheduleTaskMutation.mutate()
}
},
@@ -99,7 +101,9 @@ export default function Purge() {
const scheduleTaskMutation = useScheduleTask({
operation: 'purge',
cronExpression,
cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => {
if (!source) {
throw new Error('Please select a source path to purge')
@@ -112,9 +116,9 @@ export default function Purge() {
if (startPurgeMutation.isPending) return 'STARTING...'
if (!source) return 'Please select a source path'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE PURGE'
if (advanced.cronExpression) return 'START AND SCHEDULE PURGE'
return 'START PURGE'
}, [startPurgeMutation.isPending, source, jsonError, cronExpression])
}, [startPurgeMutation.isPending, source, jsonError, advanced.cronExpression])
const buttonIcon = useMemo(() => {
if (startPurgeMutation.isPending) return
@@ -140,13 +144,8 @@ export default function Purge() {
/>
),
},
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
],
[configGroup, globalFlags, configFlags, cronExpression]
[configGroup, globalFlags, configFlags]
)
const handleStart = useCallback(() => startPurgeMutation.mutate(), [startPurgeMutation.mutate])
@@ -167,20 +166,20 @@ export default function Purge() {
const handleResetOptions = useCallback(() => {
startTransition(() => {
resetJson()
setCronExpression(null)
advanced.reset()
startPurgeMutation.reset()
})
}, [resetJson, startPurgeMutation.reset])
}, [advanced.reset, resetJson, startPurgeMutation.reset])
const handleResetAll = useCallback(() => {
startTransition(() => {
resetJson()
resetLocks()
setCronExpression(null)
advanced.reset()
setSource(undefined)
startPurgeMutation.reset()
})
}, [resetJson, resetLocks, startPurgeMutation.reset])
}, [advanced.reset, resetJson, resetLocks, startPurgeMutation.reset])
return (
<div className="flex flex-col h-screen gap-10">
@@ -197,6 +196,8 @@ export default function Purge() {
showFiles={false}
/>
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion
defaultExpandedKeys={DEFAULT_EXPANDED_KEYS}
items={accordionItems}
+256 -60
View File
@@ -1,24 +1,69 @@
import { Card, CardBody, CardHeader, Input, Tooltip, useDisclosure } from '@heroui/react'
import { Alert, Card, CardBody, CardHeader, Input, Tooltip, useDisclosure } from '@heroui/react'
import { Button, Chip } from '@heroui/react'
import { ask } from '@tauri-apps/plugin-dialog'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import CronExpressionParser from 'cron-parser'
import cronstrue from 'cronstrue'
import { formatDistance } from 'date-fns'
import { AlertCircleIcon, Clock7Icon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react'
import {
AlertCircleIcon,
Clock7Icon,
PauseIcon,
PlayIcon,
StethoscopeIcon,
Trash2Icon,
ZapIcon,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { formatErrorMessage, onErrorDialog } from '../../lib/errors'
import { buildReadablePath } from '../../lib/format'
import { useNow } from '../../lib/hooks'
import { LOCAL_HOST_ID } from '../../lib/hosts'
import {
type SchedulerTaskStatus,
schedulerDoctor,
removeScheduledTask as schedulerRemoveTask,
schedulerRunNow,
schedulerStatus,
updateScheduledTask as schedulerUpdateTask,
schedulerValidateCron,
setScheduledTaskEnabled,
useSchedulerSupported,
} from '../../lib/scheduler'
import { useHostStore } from '../../store/host'
import { usePersistedStore } from '../../store/persisted'
import type { ScheduledTask } from '../../types/schedules'
import CommandsDropdown from '../components/CommandsDropdown'
import ScheduleEditDrawer from '../components/ScheduleEditDrawer'
export default function Schedules() {
const scheduledTasks = useHostStore((state) => state.scheduledTasks)
const currentHostId = usePersistedStore((state) => state.currentHostId) ?? LOCAL_HOST_ID
const isLocalHost = currentHostId === LOCAL_HOST_ID
const supportQuery = useSchedulerSupported()
const schedulingAvailable = isLocalHost && (supportQuery.data?.supported ?? false)
const unavailableReason = isLocalHost
? (supportQuery.data?.reason ?? 'Scheduling is not available on this system.')
: 'Scheduling runs on your local machine only — switch to the local host to manage these tasks.'
const [selectedTask, setSelectedTask] = useState<ScheduledTask | null>(null)
const { isOpen, onOpen, onClose } = useDisclosure()
const statusQuery = useQuery({
queryKey: ['scheduler', 'status'],
queryFn: () => schedulerStatus(LOCAL_HOST_ID),
enabled: schedulingAvailable,
refetchInterval: 5_000,
refetchOnWindowFocus: true,
})
const statusMap = useMemo(
() => new Map((statusQuery.data ?? []).map((status) => [status.taskId, status])),
[statusQuery.data]
)
const handleOpenDrawer = useCallback(
(task: ScheduledTask) => {
setSelectedTask(task)
@@ -27,13 +72,38 @@ export default function Schedules() {
[onOpen]
)
const doctorMutation = useMutation({
mutationFn: async () => {
const checks = await schedulerDoctor()
const report = checks
.map(
(check) =>
`${check.ok ? '✓' : '✗'} ${check.name}: ${check.detail}${check.fix ? `\n → ${check.fix}` : ''}`
)
.join('\n\n')
const hasFailure = checks.some((check) => !check.ok)
await message(report, {
title: 'Scheduling diagnostics',
kind: hasFailure ? 'warning' : 'info',
})
},
onError: async (error) => {
await message(formatErrorMessage(error, 'Diagnostics failed'), {
title: 'Scheduling diagnostics',
kind: 'error',
})
},
})
if (scheduledTasks.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-screen gap-8">
<h1 className="max-w-md text-2xl font-bold text-center">
You can schedule tasks to run later, when the UI is in the background.
{schedulingAvailable || supportQuery.isLoading
? 'You can schedule tasks to run automatically, even while the app is closed.'
: unavailableReason}
</h1>
<CommandsDropdown title="New scheduled task" />
{schedulingAvailable && <CommandsDropdown title="New scheduled task" />}
</div>
)
}
@@ -43,8 +113,36 @@ export default function Schedules() {
{platform() === 'macos' && (
<div className="w-full h-10 border-b bg-content1 border-divider" />
)}
{!schedulingAvailable && !supportQuery.isLoading && (
<Alert
color="warning"
title={unavailableReason}
radius="none"
classNames={{ base: 'flex-shrink-0' }}
/>
)}
{isLocalHost && (
<div className="flex justify-end flex-shrink-0 px-2 py-1">
<Button
size="sm"
variant="light"
startContent={<StethoscopeIcon className="w-4 h-4" />}
isLoading={doctorMutation.isPending}
onPress={() => doctorMutation.mutate()}
data-focus-visible="false"
>
Diagnostics
</Button>
</div>
)}
{scheduledTasks.map((task) => (
<TaskCard key={task.id} task={task} onOpenDrawer={handleOpenDrawer} />
<TaskCard
key={task.id}
task={task}
status={statusMap.get(task.id)}
schedulingAvailable={schedulingAvailable}
onOpenDrawer={handleOpenDrawer}
/>
))}
{selectedTask && (
<ScheduleEditDrawer isOpen={isOpen} onClose={onClose} selectedTask={selectedTask} />
@@ -55,15 +153,19 @@ export default function Schedules() {
function TaskCard({
task,
status,
schedulingAvailable,
onOpenDrawer,
}: { task: ScheduledTask; onOpenDrawer: (task: ScheduledTask) => void }) {
const [isBusy, setIsBusy] = useState(false)
}: {
task: ScheduledTask
status?: SchedulerTaskStatus
schedulingAvailable: boolean
onOpenDrawer: (task: ScheduledTask) => void
}) {
const queryClient = useQueryClient()
const [isEditingName, setIsEditingName] = useState(false)
const [editingName, setEditingName] = useState(task.name)
const removeScheduledTask = useHostStore((state) => state.removeScheduledTask)
const updateScheduledTask = useHostStore((state) => state.updateScheduledTask)
useEffect(() => {
if (!isEditingName) {
setEditingName(task.name)
@@ -74,10 +176,19 @@ function TaskCard({
// their last dep change (e.g. a past occurrence kept showing as the "next run" forever).
const now = useNow()
// Next-run preview comes from Rust (the runner's own cron matcher) — JS cron libraries
// disagree with real cron on dom/dow star semantics, so computing it here could predict
// fires the native schedule never performs. The query returns the next 5; the memo picks
// the first still in the future so the label stays fresh between refetches.
const nextRunsQuery = useQuery({
queryKey: ['scheduler', 'validate-cron', task.cron],
queryFn: () => schedulerValidateCron(task.cron),
refetchInterval: 60_000,
})
const nextRun = useMemo(() => {
const parsed = CronExpressionParser.parse(task.cron, { currentDate: new Date(now) })
return parsed.hasNext() ? parsed.next().toDate() : null
}, [task.cron, now])
const upcoming = nextRunsQuery.data?.nextRuns ?? []
return upcoming.map((run) => new Date(run)).find((run) => run.getTime() > now) ?? null
}, [nextRunsQuery.data, now])
const source = useMemo(
() => ('source' in task.args ? task.args.source : task.args.sources[0]),
@@ -85,25 +196,85 @@ function TaskCard({
)
const nextRunLabel = useMemo(() => {
if (!task.isEnabled || !schedulingAvailable) {
return 'Paused'
}
if (nextRun) {
const distance = formatDistance(nextRun, new Date(now), { addSuffix: true })
return distance.charAt(0).toUpperCase() + distance.slice(1)
}
return 'Never'
}, [nextRun, now])
}, [nextRun, now, task.isEnabled, schedulingAvailable])
const isRunning = status?.running ?? false
const lastFinished = status?.lastFinished
const lastRunLabel = useMemo(() => {
if (task.isRunning) {
if (isRunning) {
return 'Running now'
}
if (task.lastRun) {
const distance = formatDistance(new Date(task.lastRun), new Date(now), {
if (lastFinished) {
const distance = formatDistance(new Date(lastFinished.ts), new Date(now), {
addSuffix: true,
})
return distance.charAt(0).toUpperCase() + distance.slice(1)
}
return 'Never'
}, [task.isRunning, task.lastRun, now])
}, [isRunning, lastFinished, now])
const invalidateScheduler = () => queryClient.invalidateQueries({ queryKey: ['scheduler'] })
const runNowMutation = useMutation({
mutationFn: () => schedulerRunNow(task.id),
onSuccess: invalidateScheduler,
onError: onErrorDialog('Run now', 'Failed to start the task', { capture: false }),
})
const toggleMutation = useMutation({
mutationFn: async () => {
if (task.isEnabled) {
const answer = await ask('Are you sure you want to disable this task?')
if (!answer) {
return
}
await setScheduledTaskEnabled(task.id, false)
} else {
await setScheduledTaskEnabled(task.id, true)
}
},
onSuccess: invalidateScheduler,
onError: onErrorDialog('Schedule', 'Failed to update the task', { capture: false }),
})
const removeMutation = useMutation({
mutationFn: async () => {
const answer = await ask('Are you sure you want to remove this task?')
if (!answer) {
return
}
await schedulerRemoveTask(task.id)
},
onSuccess: invalidateScheduler,
onError: onErrorDialog('Schedule', 'Failed to remove the task', { capture: false }),
})
const commitName = (name: string | undefined) => {
setIsEditingName(false)
if (name === task.name) {
return
}
schedulerUpdateTask(task.id, { name }).catch((error) => {
console.error('[Schedules] rename failed', error)
})
}
const errorLine = task.registrationError
? `Not scheduled: ${task.registrationError}`
: status?.warning
? status.warning
: !isRunning && lastFinished && !lastFinished.success
? lastFinished.error || 'The last run failed'
: null
return (
<Card
@@ -136,6 +307,25 @@ function TaskCard({
>
{task.operation.toUpperCase()}
</Chip>
{task.runMode === 'system' && (
<Tooltip
content="Runs even while logged out — without your session's keychain, mounted drives, or (on macOS) protected folders"
placement="bottom"
size="lg"
color="foreground"
>
<Chip
isCloseable={false}
size="lg"
variant="flat"
radius="sm"
color="secondary"
className="h-10"
>
SYSTEM
</Chip>
</Tooltip>
)}
<div className="flex flex-col gap-0">
<Tooltip
content="Tap to edit the name"
@@ -166,13 +356,11 @@ function TaskCard({
e.currentTarget.select()
}}
onBlur={() => {
updateScheduledTask(task.id, { name: editingName })
setIsEditingName(false)
commitName(editingName)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
updateScheduledTask(task.id, { name: editingName })
setIsEditingName(false)
commitName(editingName)
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setEditingName(task.name)
@@ -195,10 +383,10 @@ function TaskCard({
<div className="flex flex-col items-center justify-center gap-0.5">
<Tooltip
content={
task.isRunning
isRunning
? undefined
: task.lastRun
? new Date(task.lastRun).toLocaleDateString('en-US', {
: lastFinished
? new Date(lastFinished.ts).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
weekday: 'short',
@@ -211,7 +399,7 @@ function TaskCard({
placement="bottom"
size="lg"
color="foreground"
isDisabled={task.isRunning}
isDisabled={isRunning}
>
<Chip
isCloseable={false}
@@ -219,9 +407,9 @@ function TaskCard({
variant="flat"
radius="sm"
color={
task.isRunning
isRunning
? 'success'
: task.lastRunError
: lastFinished && !lastFinished.success
? 'danger'
: 'default'
}
@@ -250,7 +438,11 @@ function TaskCard({
size="lg"
variant="flat"
radius="sm"
color={'primary'}
color={
task.isEnabled && schedulingAvailable
? 'primary'
: 'default'
}
>
{nextRunLabel}
</Chip>
@@ -259,25 +451,30 @@ function TaskCard({
</div>
</div>
<div className="flex flex-row justify-end gap-2">
<Tooltip content="Run now" placement="bottom" size="lg" color="foreground">
<Button
isIconOnly={true}
color="success"
variant="flat"
isDisabled={
!schedulingAvailable ||
!task.isEnabled ||
isRunning ||
runNowMutation.isPending
}
size="sm"
onPress={() => runNowMutation.mutate()}
data-focus-visible="false"
>
<ZapIcon className="w-4 h-4" />
</Button>
</Tooltip>
<Button
isIconOnly={true}
color={task.isEnabled ? 'primary' : 'warning'}
isDisabled={isBusy}
isDisabled={!schedulingAvailable || toggleMutation.isPending}
size="sm"
onPress={async () => {
setIsBusy(true)
if (task.isEnabled) {
const answer = await ask(
'Are you sure you want to disable this task? This will not stop the current run.'
)
if (answer) {
updateScheduledTask(task.id, { isEnabled: false })
}
} else {
updateScheduledTask(task.id, { isEnabled: true })
}
setIsBusy(false)
}}
onPress={() => toggleMutation.mutate()}
data-focus-visible="false"
>
{task.isEnabled ? (
@@ -289,18 +486,9 @@ function TaskCard({
<Button
isIconOnly={true}
color="danger"
isDisabled={isBusy}
isDisabled={removeMutation.isPending}
size="sm"
onPress={async () => {
setIsBusy(true)
const answer = await ask(
'Are you sure you want to remove this task?'
)
if (answer) {
removeScheduledTask(task.id)
}
setIsBusy(false)
}}
onPress={() => removeMutation.mutate()}
data-focus-visible="false"
>
<Trash2Icon className="w-4 h-4" />
@@ -310,16 +498,16 @@ function TaskCard({
</CardHeader>
<CardBody>
<div className="flex flex-row items-center justify-start gap-1 text-sm font-bold">
{task.lastRunError ? (
{errorLine ? (
<>
<AlertCircleIcon className="w-4 h-4 text-danger-600" />
<p className="text-sm font-bold text-danger-600">{task.lastRunError}</p>
<p className="text-sm font-bold text-danger-600">{errorLine}</p>
</>
) : (
<>
<Clock7Icon className="w-4 h-4" />
<p className="text-sm font-bold truncate">
{cronstrue.toString(task.cron)}.
{safeCronDescription(task.cron)}
</p>
</>
)}
@@ -328,3 +516,11 @@ function TaskCard({
</Card>
)
}
function safeCronDescription(cron: string) {
try {
return `${cronstrue.toString(cron)}.`
} catch {
return cron
}
}
+22 -3
View File
@@ -22,6 +22,7 @@ import {
import { MIN_RCLONE_VERSION } from '../../../lib/rclone/constants'
import {
type DownloadProgress,
type DownloadedVersion,
activateRclonePath,
deleteVersion,
downloadVersion,
@@ -30,6 +31,7 @@ import {
listDownloadedVersions,
setPathIntegration,
} from '../../../lib/rclone/versions'
import { useHostStore } from '../../../store/host'
import { usePersistedStore } from '../../../store/persisted'
import BaseSection from './BaseSection'
@@ -117,6 +119,25 @@ export default function BinarySection() {
},
})
const scheduledTasks = useHostStore((state) => state.scheduledTasks)
const handleDeleteVersion = async (v: DownloadedVersion) => {
// Schedules can pin a specific downloaded version by absolute path — deleting it would
// make every later run fail with "binary not found". Being the active global binary is
// not the only way a version can be in use. (Schedules are local-host-only, so the host
// store is authoritative here.)
const pinnedBy = scheduledTasks.filter((task) => task.binaryPath === v.path)
if (pinnedBy.length > 0) {
const names = pinnedBy.map((task) => task.name || task.operation).join(', ')
await message(
`This version is used by ${pinnedBy.length} scheduled task(s): ${names}. Change their rclone binary in the schedule settings first.`,
{ title: 'Version in use', kind: 'warning' }
)
return
}
deleteMutation.mutate(v.version)
}
const downloadedVersions = downloadedQuery.data ?? []
const downloadedSet = useMemo(
() => new Set(downloadedVersions.map((v) => v.version)),
@@ -191,9 +212,7 @@ export default function BinarySection() {
actionLabel="Use"
isActivating={activateMutation.isPending}
onActivate={() => activateMutation.mutate({ path: v.path })}
onDelete={
isActive ? undefined : () => deleteMutation.mutate(v.version)
}
onDelete={isActive ? undefined : () => handleDeleteVersion(v)}
isDeleting={
deleteMutation.isPending &&
deleteMutation.variables === v.version
+17 -17
View File
@@ -7,12 +7,14 @@ import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks'
import { startDryRun, startSync } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection'
import { PathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection'
import AdvancedScheduleSection, {
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
@@ -69,7 +71,7 @@ Expand the accordion sections to customize your sync operation. Tap any chip on
• Filters — Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age).
• Cron — Schedule this sync to run automatically at set intervals. The schedule only triggers while the app is running.
• Cron — Schedule this sync to run automatically at set intervals. It runs on a system schedule, even when the app is closed.
• Config — Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes.
@@ -113,7 +115,7 @@ export default function Sync() {
const filterGroup = optionGroups.filter
const configGroup = optionGroups.config
const [cronExpression, setCronExpression] = useState<string | null>(null)
const advanced = useAdvancedSchedule()
const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest])
@@ -137,7 +139,7 @@ export default function Sync() {
return startSync(buildArgs())
},
onSuccess: () => {
if (cronExpression) {
if (advanced.cronExpression) {
scheduleTaskMutation.mutate()
}
},
@@ -146,7 +148,9 @@ export default function Sync() {
const scheduleTaskMutation = useScheduleTask({
operation: 'sync',
cronExpression,
cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => {
if (!source || !dest) {
throw new Error('Please select both a source and destination path')
@@ -179,9 +183,9 @@ export default function Sync() {
if (!dest) return 'Please select a destination path'
if (source === dest) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE SYNC'
if (advanced.cronExpression) return 'START AND SCHEDULE SYNC'
return 'START SYNC'
}, [startSyncMutation.isPending, source, dest, jsonError, cronExpression])
}, [startSyncMutation.isPending, source, dest, jsonError, advanced.cronExpression])
const buttonIcon = useMemo(() => {
if (startSyncMutation.isPending) return
@@ -222,11 +226,6 @@ export default function Sync() {
/>
),
},
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
{
key: 'config',
category: 'config',
@@ -275,7 +274,6 @@ export default function Sync() {
syncFlags,
filterFlags,
configFlags,
cronExpression,
selectedRemotes,
remotesGroup,
]
@@ -302,21 +300,21 @@ export default function Sync() {
const handleResetOptions = useCallback(() => {
startTransition(() => {
resetJson()
setCronExpression(null)
advanced.reset()
startSyncMutation.reset()
})
}, [resetJson, startSyncMutation.reset])
}, [advanced.reset, resetJson, startSyncMutation.reset])
const handleResetAll = useCallback(() => {
startTransition(() => {
resetJson()
resetLocks()
setCronExpression(null)
advanced.reset()
setDest(undefined)
setSource(undefined)
startSyncMutation.reset()
})
}, [resetJson, resetLocks, startSyncMutation.reset])
}, [advanced.reset, resetJson, resetLocks, startSyncMutation.reset])
return (
<div className="flex flex-col h-screen gap-10">
@@ -332,6 +330,8 @@ export default function Sync() {
destOptions={DEST_OPTIONS}
/>
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion banner={true} items={accordionItems} />
</OperationWindowContent>