interface adjustments

This commit is contained in:
FTCHD
2026-07-12 01:16:46 +03:00
parent 1f8b51310e
commit 367d0b3be5
29 changed files with 1085 additions and 996 deletions
+33 -10
View File
@@ -1,8 +1,7 @@
import { sep } from '@tauri-apps/api/path'
const RE_WINDOWS_DRIVE = /^[a-zA-Z]:([/\\]|$)/
const RE_WINDOWS_DRIVE_WITH_SLASH = /^([a-zA-Z]:)\/?/
const RE_LOCAL_WINDOWS_PATH = /^:local:([a-zA-Z]:\/?.*)$/
const RE_LOCAL_PREFIX = /^:local:/
const RE_PATH_SEPARATOR = /[/\\]/
export function formatBytes(bytes: number) {
@@ -48,21 +47,45 @@ export function getRemoteName(path?: string) {
}
export function buildReadablePath(path: string, type: 'short' | 'long' = 'long') {
console.log('[buildReadablePath] path', path)
console.log('[buildReadablePath] sep()', sep())
if (!path) {
return ''
}
const lastSegment = path.split(RE_PATH_SEPARATOR).filter(Boolean).pop()
console.log('[buildReadablePath] lastSegment', lastSegment)
if (type === 'short') {
return lastSegment
return path.split(RE_PATH_SEPARATOR).filter(Boolean).pop() ?? ''
}
return `${path.split(':')[0]}:/.../${lastSegment}`
// 'long' = a compact readable path (toolbar command-palette style): an optional container
// (remote-name or Windows drive, each with its colon), then the path abbreviated to
// "<first>/.../<parent>/<name>" (fewer joints for short paths). A plain local (Unix) path has no
// container, so its own first segment (/Users, /Volumes, /mnt, …) is the leading item. Never
// colon-split blindly — that mangles Unix paths.
const remote = getRemoteName(path)
let prefix = ''
let rest = path
if (remote && remote !== ':local') {
prefix = `${remote}:`
rest = path.slice(path.indexOf(':') + 1)
} else {
rest = path.replace(RE_LOCAL_PREFIX, '') // defensive; :local: isn't stored but keeps C:/ intact
const drive = rest.match(RE_WINDOWS_DRIVE_WITH_SLASH)
if (drive) {
prefix = drive[1]
rest = rest.slice(drive[0].length)
}
}
const body = abbreviateSegments(rest.split(RE_PATH_SEPARATOR).filter(Boolean))
return `${prefix}/${body}`
}
// Keep the first, immediate-parent, and last path segments, collapsing anything between to "…".
function abbreviateSegments(segments: string[]): string {
if (segments.length <= 3) {
return segments.join('/')
}
return `${segments[0]}/.../${segments[segments.length - 2]}/${segments[segments.length - 1]}`
}
export function buildReadablePathMultiple(
+7 -3
View File
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import type { RcloneFeatures, RcloneFsInfo } from '../types/rclone'
import { UserCancelledError } from './errors'
import { sortByName } from './flags'
import rclone from './rclone/client'
import { SERVE_TYPES } from './rclone/constants'
@@ -47,11 +48,14 @@ export function useRemoteConfig(remote: string | undefined | null) {
export function fsInfoQueryOptions(remote: string | undefined | null) {
return {
queryKey: ['remote', remote, 'fsinfo'] as const,
queryFn: () =>
rclone('/operations/fsinfo', { params: { query: { fs: `${remote}:` } } }),
queryFn: () => rclone('/operations/fsinfo', { params: { query: { fs: `${remote}:` } } }),
enabled: !!remote && remote !== 'UI_LOCAL_FS' && remote !== 'UI_FAVORITES',
staleTime: 1000 * 60 * 60 * 24,
retry: 1,
// Cap retries low (dead remotes shouldn't retry 3× like the default) but keep the default's
// UserCancelledError guard: a plain `retry: 1` would override it, and since fsinfo connects
// to the remote, retrying a dismissed reconnect prompt re-prompts the user (client.ts).
retry: (failureCount: number, error: unknown) =>
!(error instanceof UserCancelledError) && failureCount < 1,
}
}
+9
View File
@@ -186,6 +186,9 @@ export const NOTIFICATION_PROVIDERS: Record<
description: string
urlPlaceholder: string
accentClass: string
// Official docs page for obtaining the webhook/bot token, shown as a button in the drawer.
helpUrl?: string
helpLabel?: string
}
> = {
discord: {
@@ -194,6 +197,8 @@ export const NOTIFICATION_PROVIDERS: Record<
description: 'Post to a Discord channel',
urlPlaceholder: 'https://discord.com/api/webhooks/1234567890/AbCdEf...',
accentClass: 'text-indigo-500',
helpUrl: 'https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks',
helpLabel: 'How to create a webhook',
},
slack: {
label: 'Slack',
@@ -201,6 +206,8 @@ export const NOTIFICATION_PROVIDERS: Record<
description: 'Post to a Slack channel',
urlPlaceholder: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX',
accentClass: 'text-emerald-500',
helpUrl: 'https://api.slack.com/messaging/webhooks',
helpLabel: 'How to create a webhook',
},
telegram: {
label: 'Telegram',
@@ -208,6 +215,8 @@ export const NOTIFICATION_PROVIDERS: Record<
description: 'Message a chat via your bot',
urlPlaceholder: 'https://api.telegram.org/bot123456:ABC-DEF...',
accentClass: 'text-sky-500',
helpUrl: 'https://core.telegram.org/bots#how-do-i-create-a-bot',
helpLabel: 'How to create a bot & get a token',
},
webhook: {
label: 'Webhook',
+11
View File
@@ -96,6 +96,17 @@ export function useSchedulerSupported() {
})
}
/**
* Whether scheduling can actually work here: the current host is the local machine AND the OS
* backend reports support. Unresolved support counts as unavailable. Shared by the operation
* pages (to gate the Cron section + footer Schedule button) and the footer.
*/
export function useSchedulingAvailable(): boolean {
const currentHostId = usePersistedStore((s) => s.currentHostId) ?? LOCAL_HOST_ID
const support = useSchedulerSupported()
return currentHostId === LOCAL_HOST_ID && (support.data?.supported ?? false)
}
export interface CronValidation {
valid: boolean
error?: string
+1 -1
View File
@@ -1,4 +1,4 @@
{
"minimumVersion": "3.1.0",
"okVersion": "3.1.0"
"okVersion": "3.7.0"
}
+10 -1
View File
@@ -12,7 +12,16 @@
"url": "https://github.com/rclone-ui/rclone-ui"
},
"homepage": "https://rcloneui.com",
"keywords": ["rclone", "gui", "drive", "s3", "cloud", "storage", "sync", "backup"],
"keywords": [
"rclone",
"gui",
"drive",
"s3",
"cloud",
"storage",
"sync",
"backup"
],
"license": "Apache-2.0",
"scripts": {
"dev": "node scripts/buildExternal.js && vite",
+2 -1
View File
@@ -73,7 +73,8 @@ export default function BinarySelect({
return (
<Select
label={label}
label={label || undefined}
aria-label={label ? undefined : 'rclone binary'}
labelPlacement="outside"
selectedKeys={[isCustomBinary ? CUSTOM_BINARY : value]}
onSelectionChange={(keys) => {
+2 -1
View File
@@ -18,7 +18,8 @@ export default function ConfigSelect({
}) {
return (
<Select
label={label}
label={label || undefined}
aria-label={label ? undefined : 'Config file'}
labelPlacement="outside"
selectedKeys={value ? [value] : []}
onSelectionChange={(keys) => {
+3 -2
View File
@@ -29,11 +29,12 @@ export default function CronEditor({ expression, onChange, error }: CronEditorPr
)
const readableDescription = useMemo(() => {
if (!cronExpression) return 'This task is not scheduled'
if (!cronExpression)
return 'Enter a cron expression to have this task run at regular intervals'
let description: string
try {
description = cronstrue.toString(cronExpression)
description += '. Runs on a system schedule, even when the app is closed.'
description += '. Runs even when the app is closed.'
} catch {
description = 'Invalid cron expression'
}
@@ -1,4 +1,5 @@
import {
Alert,
Button,
Checkbox,
CheckboxGroup,
@@ -13,7 +14,9 @@ import {
} from '@heroui/react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { message } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
import { platform } from '@tauri-apps/plugin-os'
import { ExternalLinkIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import {
NOTIFICATION_PROVIDERS,
@@ -304,6 +307,29 @@ export default function NotificationTargetDrawer({
)}
</section>
{!!providerMeta.helpUrl && (
<Button
fullWidth={true}
variant="flat"
startContent={<ExternalLinkIcon className="w-4 h-4" />}
onPress={() => openUrl(providerMeta.helpUrl!)}
data-focus-visible="false"
>
{providerMeta.helpLabel}
</Button>
)}
{isTelegram && (
<Alert
color="primary"
variant="faded"
title="Start the bot first"
>
Open your bot in Telegram and tap Start (or send it any
message). Telegram won't let a bot message you until you do.
</Alert>
)}
<section className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<p className="text-sm font-semibold uppercase text-default-500">
+246 -156
View File
@@ -13,12 +13,12 @@ import {
Switch,
Tab,
Tabs,
Tooltip,
cn,
} from '@heroui/react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { platform } from '@tauri-apps/plugin-os'
import { format, formatDistance } from 'date-fns'
import { CalendarClockIcon } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { formatErrorMessage } from '../../lib/errors'
import { buildReadablePath } from '../../lib/format'
@@ -49,6 +49,7 @@ export default function ScheduleEditDrawer({
const queryClient = useQueryClient()
const configFiles = useHostStore((state) => state.configFiles)
const [name, setName] = useState(selectedTask.name ?? '')
const [cronExpression, setCronExpression] = useState(selectedTask.cron)
const [configId, setConfigId] = useState(selectedTask.configId)
const [binaryPath, setBinaryPath] = useState(selectedTask.binaryPath)
@@ -63,6 +64,7 @@ export default function ScheduleEditDrawer({
useEffect(() => {
if (isOpen) {
setName(selectedTask.name ?? '')
setCronExpression(selectedTask.cron)
setConfigId(selectedTask.configId)
setBinaryPath(selectedTask.binaryPath)
@@ -141,6 +143,7 @@ export default function ScheduleEditDrawer({
const hasChanges = useMemo(
() =>
name !== (selectedTask.name ?? '') ||
cronExpression !== selectedTask.cron ||
configId !== selectedTask.configId ||
binaryPath !== selectedTask.binaryPath ||
@@ -149,6 +152,7 @@ export default function ScheduleEditDrawer({
runMode !== (selectedTask.runMode ?? 'user') ||
maxRunHoursNumber !== (selectedTask.maxRunHours ?? DEFAULT_MAX_RUN_HOURS),
[
name,
cronExpression,
configId,
binaryPath,
@@ -171,6 +175,7 @@ export default function ScheduleEditDrawer({
mutationFn: async () => {
setSaveError(null)
await schedulerUpdateTask(selectedTask.id, {
name: name.trim(),
cron: cronExpression,
configId,
binaryPath,
@@ -222,9 +227,6 @@ export default function ScheduleEditDrawer({
>
{selectedTask.operation.toUpperCase()}
</Chip>
<p className="text-small text-foreground-500 line-clamp-1">
{selectedTask.name || 'Untitled Schedule'}
</p>
</div>
<Divider />
</div>
@@ -251,155 +253,214 @@ export default function ScheduleEditDrawer({
</Alert>
)}
<div className="flex flex-col gap-3">
<h3 className="text-lg font-medium">Details</h3>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1">
<p className="text-sm text-foreground-500">
Source
</p>
<p className="font-mono text-sm">
{buildReadablePath(source, 'long')}
</p>
<div className="flex flex-col gap-6">
<div className="flex flex-row justify-center w-full gap-8">
<div className="flex flex-col items-end flex-1 gap-2">
<h4 className="font-medium">Enabled</h4>
</div>
{destination && (
<div className="flex flex-col gap-1">
<p className="text-sm text-foreground-500">
Destination
</p>
<p className="font-mono text-sm">
{buildReadablePath(destination, 'long')}
<div className="flex flex-col w-3/5 gap-3">
<Switch
size="sm"
color="primary"
isSelected={isEnabled}
onValueChange={setIsEnabled}
aria-label="Enabled"
data-focus-visible="false"
/>
</div>
</div>
<div className="flex flex-row justify-center w-full gap-8">
<div className="flex flex-col items-end flex-1 gap-2">
<h4 className="font-medium">Name</h4>
</div>
<div className="flex flex-col w-3/5 gap-3">
<Input
aria-label="Schedule name"
placeholder="Untitled Schedule"
value={name}
onValueChange={setName}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
data-focus-visible="false"
/>
</div>
</div>
<div className="flex flex-row justify-center w-full gap-8">
<div className="flex flex-col items-end flex-1 gap-2">
<h4 className="font-medium">Source</h4>
</div>
<div className="flex flex-col w-3/5 gap-3">
<Tooltip
content={
<span className="font-mono break-all">
{source}
</span>
}
placement="top-start"
color="foreground"
className="max-w-md"
>
<p className="font-mono text-sm break-all w-fit">
{buildReadablePath(source, 'long')}
</p>
</Tooltip>
</div>
</div>
{destination && (
<div className="flex flex-row justify-center w-full gap-8">
<div className="flex flex-col items-end flex-1 gap-2">
<h4 className="font-medium">Destination</h4>
</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>
<div className="flex flex-col w-3/5 gap-3">
<Tooltip
content={
<span className="font-mono break-all">
{destination}
</span>
}
placement="top-start"
color="foreground"
className="max-w-md"
>
<p className="font-mono text-sm break-all w-fit">
{buildReadablePath(destination, 'long')}
</p>
</Tooltip>
</div>
</div>
)}
{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 className="flex flex-row justify-center w-full gap-8">
<div className="flex flex-col items-end flex-1 gap-2">
<h4 className="font-medium">Config</h4>
</div>
<div className="flex flex-col w-3/5 gap-3">
<ConfigSelect
configFiles={configFiles}
value={configId}
onChange={setConfigId}
label=""
placeholder={
configMissing
? 'Config no longer exists'
: undefined
}
/>
{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>
)}
</div>
</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>
<div className="flex flex-row justify-center w-full gap-8">
<div className="flex flex-col items-end flex-1 gap-2">
<h4 className="font-medium">Binary</h4>
</div>
<div className="flex flex-col w-3/5 gap-3">
<BinarySelect
value={binaryPath}
onChange={(path) => {
setSaveError(null)
setBinaryPath(path)
}}
onError={setSaveError}
label=""
/>
</div>
</div>
<div className="flex flex-row justify-center w-full gap-8">
<div className="flex flex-col items-end flex-1 gap-2">
<h4 className="font-medium">Run mode</h4>
</div>
<div className="flex flex-col w-3/5 gap-1">
<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">
Log individual transfers to the rclone log
(grows faster)
{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.${platform() === 'macos' ? ' 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.${platform() === 'macos' ? ' To read protected folders (Desktop, Documents, Downloads) or external volumes, grant Full Disk Access to /usr/sbin/cron in System Settings → Privacy & Security.' : ''}`}
</span>
</div>
</Switch>
</div>
</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" />
Upcoming Runs
</h3>
{upcomingRuns.length > 0 ? (
<div className="flex flex-col gap-2">
{upcomingRuns.map((run, index) => (
<div
key={run.toISOString()}
className="flex items-center gap-3 text-sm"
>
<Chip
size="sm"
variant="flat"
color="default"
>
{index + 1}
</Chip>
<span>
{format(run, 'EEEE, MMMM d, yyyy')}
</span>
<span className="text-foreground-500">
at
</span>
<span className="font-mono">
{format(run, 'HH:mm')}
<div className="flex flex-row justify-center w-full gap-8">
<div className="flex flex-col items-end flex-1 gap-2">
<h4 className="font-medium">Logging</h4>
</div>
<div className="flex flex-col w-3/5 gap-3">
<Switch
size="sm"
color="primary"
isSelected={verboseLogging}
onValueChange={setVerboseLogging}
data-focus-visible="false"
>
<div className="flex flex-col">
<span className="text-small">Verbose</span>
<span className="text-tiny text-default-400">
Log individual transfers to the rclone
log
</span>
</div>
))}
</Switch>
</div>
) : (
<p className="text-sm text-foreground-500">
No upcoming runs scheduled (invalid cron expression)
</p>
)}
</div>
<div className="flex flex-row justify-center w-full gap-8">
<div className="flex flex-col items-end flex-1 gap-2">
<h4 className="font-medium">Max run time</h4>
</div>
<div className="flex flex-col w-3/5 gap-2">
<Input
type="number"
aria-label="Max run time in hours"
endContent={
<span className="text-small text-default-400">
hours
</span>
}
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}`}
className="max-w-36"
data-focus-visible="false"
/>
</div>
</div>
</div>
<Divider />
@@ -418,21 +479,37 @@ export default function ScheduleEditDrawer({
<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"
/>
<h3 className="text-lg font-medium">Upcoming Runs</h3>
{upcomingRuns.length > 0 ? (
<div className="flex flex-row justify-between">
<div className="flex flex-col gap-2">
{upcomingRuns.slice(0, 5).map((run, index) => (
<UpcomingRunRow
key={run.toISOString()}
run={run}
index={index}
/>
))}
</div>
{upcomingRuns.length > 5 && (
<div className="flex flex-col gap-2">
{upcomingRuns
.slice(5, 10)
.map((run, index) => (
<UpcomingRunRow
key={run.toISOString()}
run={run}
index={index + 5}
/>
))}
</div>
)}
</div>
) : (
<p className="text-sm text-foreground-500">
No upcoming runs scheduled (invalid cron expression)
</p>
)}
</div>
<Divider />
@@ -563,3 +640,16 @@ export default function ScheduleEditDrawer({
</Drawer>
)
}
function UpcomingRunRow({ run, index }: { run: Date; index: number }) {
return (
<div className="flex items-center gap-3 text-sm">
<Chip size="sm" variant="flat" color="default">
{index + 1}
</Chip>
<span>{format(run, 'EEEE, MMMM d, yyyy')}</span>
<span className="text-foreground-500">at</span>
<span className="font-mono">{format(run, 'HH:mm')}</span>
</div>
)
}
@@ -1,139 +0,0 @@
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>
)
}
+27
View File
@@ -0,0 +1,27 @@
import { useQuery } from '@tanstack/react-query'
import { schedulerValidateCron } from '../../../lib/scheduler'
import CronEditor from '../CronEditor'
/**
* The Cron options-accordion section for the operation pages: the cron editor plus live
* per-platform validation (scheduler_validate_cron). Rendered as the 'cron' accordion item.
*/
export default function CronSection({
expression,
onChange,
}: {
expression: string | null
onChange: (expr: string | null) => void
}) {
const validation = useQuery({
queryKey: ['scheduler', 'validate-cron', expression],
queryFn: () => schedulerValidateCron(expression ?? ''),
enabled: !!expression,
})
const error =
expression && validation.data && !validation.data.valid
? (validation.data.error ?? 'Invalid cron expression')
: null
return <CronEditor expression={expression} onChange={onChange} error={error} />
}
+7 -20
View File
@@ -11,10 +11,8 @@ 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 { useSchedulingAvailable } 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'
@@ -76,18 +74,8 @@ 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')
// (sandboxed installs, remote hosts) — mirrors the Cron options section on the operation pages.
const schedulingAvailable = useSchedulingAvailable()
const handleStartPress = useCallback(() => {
setTimeout(() => onStart(), 100)
@@ -211,20 +199,19 @@ export default function OperationFooter({
</Button>
</Tooltip>
) : null}
<Tooltip content={scheduleTooltip} placement="top" size="lg" color="foreground">
<div>
{schedulingAvailable ? (
<Tooltip content="Schedule task" placement="top" size="lg" color="foreground">
<Button
size="lg"
type="button"
color="primary"
isIconOnly={true}
isDisabled={!schedulingAvailable}
onPress={handleSchedulePress}
>
<ClockIcon className="size-6" />
</Button>
</div>
</Tooltip>
</Tooltip>
) : null}
<CommandInfoButton content={helpContent} />
<CommandsDropdown currentCommand={operation} />
</ButtonGroup>
@@ -1,6 +1,7 @@
import { Accordion, AccordionItem, Avatar } from '@heroui/react'
import {
ChevronDownIcon,
ClockIcon,
CopyIcon,
DiamondPercentIcon,
FilterIcon,
@@ -14,7 +15,7 @@ import { usePersistedStore } from '../../../store/persisted'
// Avatar/indicator/title per option category — exactly what each page's accordion rendered.
export const CATEGORY_META: Record<
'copy' | 'sync' | 'move' | 'bisync' | 'filters' | 'config' | 'remotes',
'copy' | 'sync' | 'move' | 'bisync' | 'filters' | 'cron' | 'config' | 'remotes',
{
title: string
icon: ComponentType<{ className?: string }>
@@ -33,6 +34,7 @@ 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' },
}
@@ -49,7 +51,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 /
* RemoteOptionsSection) stays page-supplied. `banner` wraps the accordion in the
* CronSection / 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({
+24 -12
View File
@@ -8,14 +8,13 @@ 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 { useSchedulingAvailable } from '../../lib/scheduler'
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 CronSection from '../components/operation/CronSection'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
@@ -103,7 +102,8 @@ export default function Bisync() {
const [outerBisyncOptions, setOuterBisyncOptions] = useState<Record<string, boolean>>({})
const advanced = useAdvancedSchedule()
const [cronExpression, setCronExpression] = useState<string | null>(null)
const schedulingAvailable = useSchedulingAvailable()
const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest])
@@ -141,7 +141,7 @@ export default function Bisync() {
return startBisync(buildStartArgs())
},
onSuccess: () => {
if (advanced.cronExpression) {
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
@@ -153,9 +153,7 @@ export default function Bisync() {
const scheduleTaskMutation = useScheduleTask({
operation: 'bisync',
cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
cronExpression,
validate: () => {
if (!source || !dest) {
throw new Error('Please select both a source and destination path')
@@ -170,9 +168,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 (advanced.cronExpression) return 'START AND SCHEDULE BISYNC'
if (cronExpression) return 'START AND SCHEDULE BISYNC'
return 'START BISYNC'
}, [startBisyncMutation.isPending, source, dest, jsonError, advanced.cronExpression])
}, [startBisyncMutation.isPending, source, dest, jsonError, cronExpression])
const buttonIcon = useMemo(() => {
if (startBisyncMutation.isPending) return
@@ -320,6 +318,20 @@ export default function Bisync() {
/>
),
},
...(schedulingAvailable
? [
{
key: 'cron',
category: 'cron' as const,
children: (
<CronSection
expression={cronExpression}
onChange={setCronExpression}
/>
),
},
]
: []),
{
key: 'config',
category: 'config',
@@ -371,6 +383,8 @@ export default function Bisync() {
configFlags,
selectedRemotes,
remotesGroup,
cronExpression,
schedulingAvailable,
]
)
@@ -424,8 +438,6 @@ export default function Bisync() {
setDestPath={setDest}
/>
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion items={accordionItems} />
</OperationWindowContent>
+29 -17
View File
@@ -7,15 +7,14 @@ import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks'
import { startCopy, startDryRun } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { useSchedulingAvailable } from '../../lib/scheduler'
import { usePersistedStore } from '../../store/persisted'
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 CronSection from '../components/operation/CronSection'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
@@ -42,7 +41,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. It runs on a system schedule, even when the app is closed.
• Cron — Schedule this copy to run automatically at set intervals. It runs 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.
@@ -86,7 +85,8 @@ export default function Copy() {
const filterGroup = optionGroups.filter
const configGroup = optionGroups.config
const advanced = useAdvancedSchedule()
const [cronExpression, setCronExpression] = useState<string | null>(null)
const schedulingAvailable = useSchedulingAvailable()
const selectedRemotes = useMemo(
() => [...(sources || []), dest].filter(Boolean),
@@ -113,7 +113,7 @@ export default function Copy() {
return startCopy(buildArgs())
},
onSuccess: () => {
if (advanced.cronExpression) {
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
@@ -125,9 +125,7 @@ export default function Copy() {
const scheduleTaskMutation = useScheduleTask({
operation: 'copy',
cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
cronExpression,
validate: () => {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
@@ -164,9 +162,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 (advanced.cronExpression) return 'START AND SCHEDULE COPY'
if (cronExpression) return 'START AND SCHEDULE COPY'
return 'START COPY'
}, [startCopyMutation.isPending, sources, dest, jsonError, advanced.cronExpression])
}, [startCopyMutation.isPending, sources, dest, jsonError, cronExpression])
const buttonIcon = useMemo(() => {
if (startCopyMutation.isPending) return
@@ -208,6 +206,20 @@ export default function Copy() {
/>
),
},
...(schedulingAvailable
? [
{
key: 'cron',
category: 'cron' as const,
children: (
<CronSection
expression={cronExpression}
onChange={setCronExpression}
/>
),
},
]
: []),
{
key: 'config',
category: 'config',
@@ -258,6 +270,8 @@ export default function Copy() {
filterFlags,
configFlags,
selectedRemotes,
cronExpression,
schedulingAvailable,
]
)
@@ -282,21 +296,21 @@ export default function Copy() {
const handleResetOptions = useCallback(() => {
startTransition(() => {
resetJson()
advanced.reset()
setCronExpression(null)
startCopyMutation.reset()
})
}, [advanced.reset, resetJson, startCopyMutation.reset])
}, [resetJson, startCopyMutation.reset])
const handleResetAll = useCallback(() => {
startTransition(() => {
resetJson()
resetLocks()
advanced.reset()
setCronExpression(null)
setSources(undefined)
setDest(undefined)
startCopyMutation.reset()
})
}, [advanced.reset, resetJson, resetLocks, startCopyMutation.reset])
}, [resetJson, resetLocks, startCopyMutation.reset])
useEffect(() => {
console.log('[Copy] remoteOptions', remotesGroup.options)
@@ -315,8 +329,6 @@ export default function Copy() {
setDestPath={setDest}
/>
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion banner={true} items={accordionItems} />
</OperationWindowContent>
+36 -18
View File
@@ -10,13 +10,12 @@ import { hasFeature, useFlags, useFsInfo } from '../../lib/hooks'
import { notify } from '../../lib/notifications'
import { startDelete, startDryRun } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { useSchedulingAvailable } from '../../lib/scheduler'
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 CronSection from '../components/operation/CronSection'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
@@ -45,7 +44,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. It runs on a system schedule, even when the app is closed.
• Cron — Schedule this delete to run automatically at set intervals. Useful for automated cleanup tasks. It runs 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.
@@ -61,7 +60,8 @@ export default function Delete() {
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
)
const advanced = useAdvancedSchedule()
const [cronExpression, setCronExpression] = useState<string | null>(null)
const schedulingAvailable = useSchedulingAvailable()
const {
jsonError,
@@ -108,7 +108,7 @@ export default function Delete() {
title: 'Success',
body: 'Delete task started',
})
if (advanced.cronExpression) {
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
@@ -119,9 +119,7 @@ export default function Delete() {
const scheduleTaskMutation = useScheduleTask({
operation: 'delete',
cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
cronExpression,
validate: () => {
if (!sourceFs) {
throw new Error('Please select a source path to delete')
@@ -149,9 +147,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 (advanced.cronExpression) return 'START AND SCHEDULE DELETE'
if (cronExpression) return 'START AND SCHEDULE DELETE'
return 'START DELETE'
}, [startDeleteMutation.isPending, sourceFs, jsonError, advanced.cronExpression])
}, [startDeleteMutation.isPending, sourceFs, jsonError, cronExpression])
const buttonIcon = useMemo(() => {
if (startDeleteMutation.isPending) return
@@ -192,8 +190,30 @@ export default function Delete() {
/>
),
},
...(schedulingAvailable
? [
{
key: 'cron',
category: 'cron' as const,
children: (
<CronSection
expression={cronExpression}
onChange={setCronExpression}
/>
),
},
]
: []),
],
[filterGroup, configGroup, globalFlags, filterFlags, configFlags]
[
filterGroup,
configGroup,
globalFlags,
filterFlags,
configFlags,
cronExpression,
schedulingAvailable,
]
)
const handleStart = useCallback(
@@ -219,20 +239,20 @@ export default function Delete() {
const handleResetOptions = useCallback(() => {
startTransition(() => {
resetJson()
advanced.reset()
setCronExpression(null)
startDeleteMutation.reset()
})
}, [advanced.reset, resetJson, startDeleteMutation.reset])
}, [resetJson, startDeleteMutation.reset])
const handleResetAll = useCallback(() => {
startTransition(() => {
resetJson()
resetLocks()
advanced.reset()
setCronExpression(null)
setSourceFs(undefined)
startDeleteMutation.reset()
})
}, [advanced.reset, resetJson, resetLocks, startDeleteMutation.reset])
}, [resetJson, resetLocks, startDeleteMutation.reset])
return (
<div className="flex flex-col h-screen gap-10">
@@ -261,8 +281,6 @@ export default function Delete() {
</Alert>
)}
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion items={accordionItems} />
</OperationWindowContent>
+29 -17
View File
@@ -7,15 +7,14 @@ import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks'
import { startDryRun, startMove } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { useSchedulingAvailable } from '../../lib/scheduler'
import { usePersistedStore } from '../../store/persisted'
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 CronSection from '../components/operation/CronSection'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
@@ -46,7 +45,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. It runs on a system schedule, even when the app is closed.
• Cron — Schedule this move to run automatically at set intervals. It runs 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.
@@ -90,7 +89,8 @@ export default function Move() {
const filterGroup = optionGroups.filter
const configGroup = optionGroups.config
const advanced = useAdvancedSchedule()
const [cronExpression, setCronExpression] = useState<string | null>(null)
const schedulingAvailable = useSchedulingAvailable()
const selectedRemotes = useMemo(
() => [...(sources || []), dest].filter(Boolean),
@@ -117,7 +117,7 @@ export default function Move() {
return startMove(buildArgs())
},
onSuccess: () => {
if (advanced.cronExpression) {
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
@@ -129,9 +129,7 @@ export default function Move() {
const scheduleTaskMutation = useScheduleTask({
operation: 'move',
cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
cronExpression,
validate: () => {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
@@ -168,9 +166,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 (advanced.cronExpression) return 'START AND SCHEDULE MOVE'
if (cronExpression) return 'START AND SCHEDULE MOVE'
return 'START MOVE'
}, [startMoveMutation.isPending, sources, dest, jsonError, advanced.cronExpression])
}, [startMoveMutation.isPending, sources, dest, jsonError, cronExpression])
const buttonIcon = useMemo(() => {
if (startMoveMutation.isPending) return
@@ -212,6 +210,20 @@ export default function Move() {
/>
),
},
...(schedulingAvailable
? [
{
key: 'cron',
category: 'cron' as const,
children: (
<CronSection
expression={cronExpression}
onChange={setCronExpression}
/>
),
},
]
: []),
{
key: 'config',
category: 'config',
@@ -262,6 +274,8 @@ export default function Move() {
configFlags,
copyFlags,
selectedRemotes,
cronExpression,
schedulingAvailable,
]
)
@@ -286,21 +300,21 @@ export default function Move() {
const handleResetOptions = useCallback(() => {
startTransition(() => {
resetJson()
advanced.reset()
setCronExpression(null)
startMoveMutation.reset()
})
}, [advanced.reset, resetJson, startMoveMutation.reset])
}, [resetJson, startMoveMutation.reset])
const handleResetAll = useCallback(() => {
startTransition(() => {
resetJson()
resetLocks()
advanced.reset()
setCronExpression(null)
setSources(undefined)
setDest(undefined)
startMoveMutation.reset()
})
}, [advanced.reset, resetJson, resetLocks, startMoveMutation.reset])
}, [resetJson, resetLocks, startMoveMutation.reset])
return (
<div className="flex flex-col h-screen gap-10">
@@ -314,8 +328,6 @@ export default function Move() {
setDestPath={setDest}
/>
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion banner={true} items={accordionItems} />
</OperationWindowContent>
+28 -18
View File
@@ -7,13 +7,12 @@ 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 { useSchedulingAvailable } from '../../lib/scheduler'
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 CronSection from '../components/operation/CronSection'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
@@ -43,7 +42,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. It runs on a system schedule, even when the app is closed.
• Cron — Schedule this purge to run automatically at set intervals. Useful for automated cleanup of temporary folders. It runs even when the app is closed.
3. USE TEMPLATES (Optional)
Tap the folder icon in the bottom bar to load or save option presets.
@@ -59,7 +58,8 @@ export default function Purge() {
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
)
const advanced = useAdvancedSchedule()
const [cronExpression, setCronExpression] = useState<string | null>(null)
const schedulingAvailable = useSchedulingAvailable()
const {
jsonError,
@@ -90,7 +90,7 @@ export default function Purge() {
return startPurge(buildArgs())
},
onSuccess: async () => {
if (advanced.cronExpression) {
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
@@ -101,9 +101,7 @@ export default function Purge() {
const scheduleTaskMutation = useScheduleTask({
operation: 'purge',
cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
cronExpression,
validate: () => {
if (!source) {
throw new Error('Please select a source path to purge')
@@ -116,9 +114,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 (advanced.cronExpression) return 'START AND SCHEDULE PURGE'
if (cronExpression) return 'START AND SCHEDULE PURGE'
return 'START PURGE'
}, [startPurgeMutation.isPending, source, jsonError, advanced.cronExpression])
}, [startPurgeMutation.isPending, source, jsonError, cronExpression])
const buttonIcon = useMemo(() => {
if (startPurgeMutation.isPending) return
@@ -144,8 +142,22 @@ export default function Purge() {
/>
),
},
...(schedulingAvailable
? [
{
key: 'cron',
category: 'cron' as const,
children: (
<CronSection
expression={cronExpression}
onChange={setCronExpression}
/>
),
},
]
: []),
],
[configGroup, globalFlags, configFlags]
[configGroup, globalFlags, configFlags, cronExpression, schedulingAvailable]
)
const handleStart = useCallback(() => startPurgeMutation.mutate(), [startPurgeMutation.mutate])
@@ -166,20 +178,20 @@ export default function Purge() {
const handleResetOptions = useCallback(() => {
startTransition(() => {
resetJson()
advanced.reset()
setCronExpression(null)
startPurgeMutation.reset()
})
}, [advanced.reset, resetJson, startPurgeMutation.reset])
}, [resetJson, startPurgeMutation.reset])
const handleResetAll = useCallback(() => {
startTransition(() => {
resetJson()
resetLocks()
advanced.reset()
setCronExpression(null)
setSource(undefined)
startPurgeMutation.reset()
})
}, [advanced.reset, resetJson, resetLocks, startPurgeMutation.reset])
}, [resetJson, resetLocks, startPurgeMutation.reset])
return (
<div className="flex flex-col h-screen gap-10">
@@ -196,8 +208,6 @@ export default function Purge() {
showFiles={false}
/>
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion
defaultExpandedKeys={DEFAULT_EXPANDED_KEYS}
items={accordionItems}
+8 -114
View File
@@ -1,31 +1,21 @@
import { Alert, Card, CardBody, CardHeader, Input, Tooltip, useDisclosure } from '@heroui/react'
import { Alert, Card, CardBody, CardHeader, Tooltip, useDisclosure } from '@heroui/react'
import { Button, Chip } from '@heroui/react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { ask } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import cronstrue from 'cronstrue'
import { formatDistance } from 'date-fns'
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 { AlertCircleIcon, Clock7Icon, PauseIcon, PlayIcon, Trash2Icon, ZapIcon } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { 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,
@@ -72,29 +62,6 @@ 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">
@@ -121,20 +88,6 @@ export default function Schedules() {
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}
@@ -163,14 +116,6 @@ function TaskCard({
onOpenDrawer: (task: ScheduledTask) => void
}) {
const queryClient = useQueryClient()
const [isEditingName, setIsEditingName] = useState(false)
const [editingName, setEditingName] = useState(task.name)
useEffect(() => {
if (!isEditingName) {
setEditingName(task.name)
}
}, [task.name, isEditingName])
// The card's time-derived values are anchored to this tick — without it the memos freeze at
// their last dep change (e.g. a past occurrence kept showing as the "next run" forever).
@@ -258,16 +203,6 @@ function TaskCard({
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
@@ -327,50 +262,9 @@ function TaskCard({
</Tooltip>
)}
<div className="flex flex-col gap-0">
<Tooltip
content="Tap to edit the name"
placement="bottom"
size="lg"
color="foreground"
>
<Input
size="sm"
value={
isEditingName
? editingName
: task.name || 'Untitled Schedule'
}
variant="bordered"
isReadOnly={!isEditingName}
classNames={{
'input': 'font-bold',
'inputWrapper': 'p-0 border-0 min-h-0 h-full w-64',
}}
autoCapitalize="off"
autoComplete="off"
autoCorrect="off"
spellCheck="false"
onClick={(e) => {
setEditingName(task.name || 'Untitled Schedule')
setIsEditingName(true)
e.currentTarget.select()
}}
onBlur={() => {
commitName(editingName)
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
commitName(editingName)
e.currentTarget.blur()
} else if (e.key === 'Escape') {
setEditingName(task.name)
setIsEditingName(false)
e.currentTarget.blur()
}
}}
onValueChange={(newName) => setEditingName(newName)}
/>
</Tooltip>
<p className="w-64 text-sm font-bold truncate text-start">
{task.name || 'Untitled Schedule'}
</p>
<div className="text-sm text-gray-500 text-start">
{buildReadablePath(source, 'short')} {'→'}{' '}
{'destination' in task.args
+195 -166
View File
@@ -2,7 +2,6 @@ import { Button, Checkbox, Chip, Input, Progress, Spinner, Tooltip } from '@hero
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { message, open } from '@tauri-apps/plugin-dialog'
import {
CheckIcon,
DownloadIcon,
FolderOpenIcon,
HardDriveIcon,
@@ -157,155 +156,188 @@ export default function BinarySection() {
return (
<BaseSection header={{ title: 'Binary' }}>
<div className="flex flex-col w-full gap-6 px-8 pb-10">
{/* ---- Custom binary ---- */}
<CustomBinaryRow
active={active}
systemPath={systemQuery.data ?? null}
rclonePath={rclonePath}
onActivated={invalidateActive}
/>
{/* ---- Custom binary ---- */}
<div className="flex flex-row justify-center w-full gap-8 px-8">
<div className="flex flex-col items-end flex-1 gap-2">
<h3 className="font-medium">Custom binary</h3>
<p className="text-xs text-neutral-500 text-end">
Point to an rclone binary on your machine.
</p>
</div>
<div className="flex flex-col w-3/5 gap-3">
<CustomBinaryRow
active={active}
systemPath={systemQuery.data ?? null}
rclonePath={rclonePath}
onActivated={invalidateActive}
/>
</div>
</div>
{/* ---- PATH integration ---- */}
<PathIntegrationRow
rclonePath={rclonePath}
isSystemActive={active?.kind === 'system'}
/>
{/* ---- PATH integration ---- */}
<div className="flex flex-row justify-center w-full gap-8 px-8">
<div className="flex flex-col items-end flex-1 gap-2">
<h3 className="font-medium">Path</h3>
</div>
<div className="flex flex-col w-3/5 gap-3">
<PathIntegrationRow
rclonePath={rclonePath}
isSystemActive={active?.kind === 'system'}
/>
</div>
</div>
{/* ---- Auto update ---- */}
<AutoUpdateRow />
{/* ---- Auto update ---- */}
<div className="flex flex-row justify-center w-full gap-8 px-8">
<div className="flex flex-col items-end flex-1 gap-2">
<h3 className="font-medium">Updates</h3>
</div>
<div className="flex flex-col w-3/5 gap-3">
<AutoUpdateRow />
</div>
</div>
{/* ---- Versions ---- */}
<div className="flex flex-col overflow-hidden border divide-y rounded-large border-divider divide-divider">
{/* System */}
{systemQuery.data && (
<VersionRow
label={
systemVersionQuery.data
? `System — v${systemVersionQuery.data}`
: 'System'
}
sublabel={systemQuery.data}
warning={subFloorWarning(systemVersionQuery.data)}
isActive={active?.kind === 'system'}
actionLabel="Use"
isActivating={activateMutation.isPending}
onActivate={() =>
activateMutation.mutate({
path: systemQuery.data!,
isSystem: true,
})
}
/>
)}
{/* Downloaded (managed) */}
{downloadedVersions.map((v) => {
const isActive = active?.kind === 'managed' && active.version === v.version
return (
{/* ---- Versions ---- */}
<div className="flex flex-row justify-center w-full gap-8 px-8 pb-10">
<div className="flex flex-col items-end flex-1 gap-2">
<h3 className="font-medium">Versions</h3>
</div>
<div className="flex flex-col w-3/5 gap-3">
<div className="flex flex-col overflow-hidden border divide-y rounded-large border-divider divide-divider">
{/* System */}
{systemQuery.data && (
<VersionRow
key={v.path}
label={`v${v.version}`}
sublabel={formatBytes(v.sizeBytes)}
warning={subFloorWarning(v.version)}
isActive={isActive}
label={
systemVersionQuery.data
? `System — v${systemVersionQuery.data}`
: 'System'
}
sublabel={systemQuery.data}
warning={subFloorWarning(systemVersionQuery.data)}
isActive={active?.kind === 'system'}
actionLabel="Use"
isActivating={activateMutation.isPending}
onActivate={() => activateMutation.mutate({ path: v.path })}
onDelete={isActive ? undefined : () => handleDeleteVersion(v)}
isDeleting={
deleteMutation.isPending &&
deleteMutation.variables === v.version
onActivate={() =>
activateMutation.mutate({
path: systemQuery.data!,
isSystem: true,
})
}
/>
)
})}
{/* Available to download */}
{availableToDownload.map((r) => {
const prog = progress[r.version]
const percent = prog?.total
? Math.min(100, Math.round((prog.downloaded / prog.total) * 100))
: undefined
const isDownloading =
downloadMutation.isPending && downloadMutation.variables === r.version
return (
<div key={r.version} className="flex items-center gap-3 px-4 py-3">
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm text-neutral-500">v{r.version}</span>
{isDownloading && (
<Progress
aria-label="download progress"
size="sm"
value={percent ?? 0}
isIndeterminate={percent === undefined}
className="mt-1 max-w-52"
/>
)}
</div>
<Button
size="sm"
variant="light"
isIconOnly={true}
isLoading={isDownloading}
onPress={() => downloadMutation.mutate(r.version)}
data-focus-visible="false"
>
<DownloadIcon className="w-4 h-4" />
</Button>
</div>
)
})}
{(downloadedVersions.length > 0 || systemQuery.data) &&
availableToDownload.length === 0 &&
releasesQuery.isError && (
<div className="flex items-center justify-between gap-2 px-4 py-3">
<span className="text-xs text-warning">
Couldn't load available versions (offline or rate-limited).
</span>
<Button
size="sm"
variant="light"
onPress={() => releasesQuery.refetch()}
startContent={<RefreshCwIcon className="w-3.5 h-3.5" />}
data-focus-visible="false"
>
Retry
</Button>
</div>
)}
{releasesQuery.isLoading && downloadedVersions.length === 0 && (
<div className="flex items-center justify-center py-6">
<Spinner size="sm" />
{/* Downloaded (managed) */}
{downloadedVersions.map((v) => {
const isActive =
active?.kind === 'managed' && active.version === v.version
return (
<VersionRow
key={v.path}
label={`v${v.version}`}
sublabel={formatBytes(v.sizeBytes)}
warning={subFloorWarning(v.version)}
isActive={isActive}
actionLabel="Use"
isActivating={activateMutation.isPending}
onActivate={() => activateMutation.mutate({ path: v.path })}
onDelete={isActive ? undefined : () => handleDeleteVersion(v)}
isDeleting={
deleteMutation.isPending &&
deleteMutation.variables === v.version
}
/>
)
})}
{/* Available to download */}
{availableToDownload.map((r) => {
const prog = progress[r.version]
const percent = prog?.total
? Math.min(100, Math.round((prog.downloaded / prog.total) * 100))
: undefined
const isDownloading =
downloadMutation.isPending &&
downloadMutation.variables === r.version
return (
<div key={r.version} className="flex items-center gap-3 px-4 py-3">
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm text-neutral-500">
v{r.version}
</span>
{isDownloading && (
<Progress
aria-label="download progress"
size="sm"
value={percent ?? 0}
isIndeterminate={percent === undefined}
className="mt-1 max-w-52"
/>
)}
</div>
<Button
size="sm"
variant="light"
isIconOnly={true}
isLoading={isDownloading}
onPress={() => downloadMutation.mutate(r.version)}
data-focus-visible="false"
>
<DownloadIcon className="w-4 h-4" />
</Button>
</div>
)
})}
{(downloadedVersions.length > 0 || systemQuery.data) &&
availableToDownload.length === 0 &&
releasesQuery.isError && (
<div className="flex items-center justify-between gap-2 px-4 py-3">
<span className="text-xs text-warning">
Couldn't load available versions (offline or rate-limited).
</span>
<Button
size="sm"
variant="light"
onPress={() => releasesQuery.refetch()}
startContent={<RefreshCwIcon className="w-3.5 h-3.5" />}
data-focus-visible="false"
>
Retry
</Button>
</div>
)}
{releasesQuery.isLoading && downloadedVersions.length === 0 && (
<div className="flex items-center justify-center py-6">
<Spinner size="sm" />
</div>
)}
</div>
{updateAvailable && (
<div className="flex items-center gap-2">
<Chip size="sm" color="primary" variant="flat">
Update available: v{latestVersion}
</Chip>
<Button
size="sm"
color="primary"
variant="flat"
isLoading={
downloadMutation.isPending &&
downloadMutation.variables === latestVersion
}
onPress={async () => {
const path = await downloadMutation.mutateAsync(latestVersion!)
activateMutation.mutate({ path })
}}
data-focus-visible="false"
>
Update &amp; use
</Button>
</div>
)}
</div>
{updateAvailable && (
<div className="flex items-center gap-2 -mt-3">
<Chip size="sm" color="primary" variant="flat">
Update available: v{latestVersion}
</Chip>
<Button
size="sm"
color="primary"
variant="flat"
isLoading={
downloadMutation.isPending &&
downloadMutation.variables === latestVersion
}
onPress={async () => {
const path = await downloadMutation.mutateAsync(latestVersion!)
activateMutation.mutate({ path })
}}
data-focus-visible="false"
>
Update &amp; use
</Button>
</div>
)}
</div>
</BaseSection>
)
@@ -341,13 +373,8 @@ function VersionRow({
{warning && <span className="text-xs text-warning">{warning}</span>}
</div>
{isActive ? (
<Chip
size="sm"
color="success"
variant="flat"
startContent={<CheckIcon className="w-3 h-3" />}
>
Active
<Chip size="sm" color="success" variant="flat">
ACTIVE
</Chip>
) : (
<Button
@@ -431,39 +458,38 @@ function CustomBinaryRow({
})
if (typeof selected === 'string') {
setValue(selected)
// Picking a binary implies using it — activate immediately, no separate button.
useMutationState.mutate(selected)
}
}
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Input
value={value}
onValueChange={setValue}
size="sm"
placeholder="/path/to/rclone"
autoComplete="off"
endContent={
<Input
value={value}
onValueChange={setValue}
onKeyDown={(e) => {
if (e.key === 'Enter' && value) {
useMutationState.mutate(value)
}
}}
size="lg"
placeholder="/path/to/rclone"
autoComplete="off"
endContent={
useMutationState.isPending ? (
<Spinner size="sm" />
) : (
<button
type="button"
onClick={browse}
className="transition-colors text-neutral-400 hover:text-neutral-200"
>
<FolderOpenIcon className="w-4 h-4" />
<FolderOpenIcon className="w-5 h-5" />
</button>
}
/>
<Button
size="sm"
variant="flat"
isDisabled={!value}
isLoading={useMutationState.isPending}
onPress={() => useMutationState.mutate(value)}
data-focus-visible="false"
>
Use
</Button>
</div>
)
}
/>
{isCustomActive && (
<span className="text-xs text-success">
Currently using a custom binary
@@ -538,6 +564,9 @@ function PathIntegrationRow({
>
Add rclone to PATH
</Checkbox>
<span className="text-xs text-neutral-500">
Lets you call rclone from your terminal.
</span>
{isSystemActive && (
<span className="text-xs text-neutral-500">
The system rclone is already on your PATH.
+199
View File
@@ -14,6 +14,61 @@ import { notify } from '../../../lib/notifications'
import { usePersistedStore } from '../../../store/persisted'
import BaseSection from './BaseSection'
const DEFAULT_TOOLBAR_SHORTCUT = 'CmdOrCtrl+Shift+/'
const KEY_CODE_DISPLAY_MAP: Record<string, string> = {
Backquote: '`',
Minus: '-',
Equal: '=',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Semicolon: ';',
Quote: "'",
Comma: ',',
Period: '.',
Slash: '/',
Space: 'Space',
}
function formatShortcutFromEvent(event: KeyboardEvent): string | null {
const modifiers: string[] = []
if (event.metaKey) {
modifiers.push('Command')
}
if (event.ctrlKey) {
modifiers.push('Ctrl')
}
if (event.altKey) {
modifiers.push('Alt')
}
if (event.shiftKey) {
modifiers.push('Shift')
}
const codeMapped = KEY_CODE_DISPLAY_MAP[event.code]
let key = codeMapped || event.key
if (key === 'Meta') {
key = 'Command'
} else if (key === 'Control') {
key = 'Ctrl'
} else if (key === ' ') {
key = 'Space'
} else if (key && key.length === 1) {
key = key.toUpperCase()
} else if (key) {
key = key.charAt(0).toUpperCase() + key.slice(1)
}
if (!key || ['Shift', 'Ctrl', 'Alt', 'Command'].includes(key)) {
return null
}
const uniqueModifiers = Array.from(new Set(modifiers))
return [...uniqueModifiers, key].join('+')
}
export default function GeneralSection() {
const settingsPass = usePersistedStore((state) => state.settingsPass)
const setSettingsPass = usePersistedStore((state) => state.setSettingsPass)
@@ -317,6 +372,8 @@ export default function GeneralSection() {
</div>
</div>
<ToolbarShortcutRow />
{!isFlathub && (
<div className="flex flex-row justify-center w-full gap-8 px-8">
<div className="flex flex-col items-end flex-grow gap-2">
@@ -336,3 +393,145 @@ export default function GeneralSection() {
</BaseSection>
)
}
// Toolbar shortcut recorder — relocated here from the retired Toolbar settings tab. Owns its own
// recording state so it stays a self-contained "Toolbar Shortcut" row in the General layout.
function ToolbarShortcutRow() {
const toolbarShortcut = usePersistedStore((state) => state.toolbarShortcut)
const setToolbarShortcut = usePersistedStore((state) => state.setToolbarShortcut)
const [isRecording, setIsRecording] = useState(false)
const [isSaving] = useState(false)
const [feedback, setFeedback] = useState<string | null>(null)
const resolvedShortcut = useMemo(
() => toolbarShortcut ?? DEFAULT_TOOLBAR_SHORTCUT,
[toolbarShortcut]
)
const updateToolbarShortcutMutation = useMutation({
mutationFn: async (value: string) => {
const normalizedShortcut = value === DEFAULT_TOOLBAR_SHORTCUT ? undefined : value
await setToolbarShortcut(normalizedShortcut)
},
onSuccess: (_, value) => {
startTransition(() => setFeedback(`Toolbar shortcut updated to ${value}`))
},
onError: async (error) => {
console.error('[Toolbar] Failed to update shortcut', error)
setFeedback('Failed to update toolbar shortcut')
await message('Failed to update toolbar shortcut', {
title: 'Toolbar',
kind: 'error',
})
},
})
useEffect(() => {
if (!isRecording) {
return
}
const handleKeyDown = (event: KeyboardEvent) => {
event.preventDefault()
event.stopPropagation()
if (isSaving) {
return
}
const shortcut = formatShortcutFromEvent(event)
if (!shortcut) {
startTransition(() => {
setFeedback('Press a non-modifier key to finish recording…')
})
return
}
startTransition(() => {
setIsRecording(false)
setFeedback(`Saving ${shortcut}`)
})
updateToolbarShortcutMutation.mutate(shortcut)
}
window.addEventListener('keydown', handleKeyDown, { capture: true })
return () => {
window.removeEventListener('keydown', handleKeyDown, { capture: true })
}
}, [isRecording, isSaving, updateToolbarShortcutMutation.mutate])
return (
<div className="flex flex-row justify-center w-full gap-8 px-8">
<div className="flex flex-col items-end flex-1 gap-2">
<div className="flex items-center gap-2">
<h3 className="font-medium">Toolbar Shortcut</h3>
{isRecording && (
<Chip color="primary" size="sm">
Recording
</Chip>
)}
</div>
<p className="text-xs text-neutral-500 text-end">
Press &quot;Record shortcut&quot; and then the desired key combination.
</p>
</div>
<div className="flex flex-col w-3/5 gap-3">
<Input
label="Current"
value={
toolbarShortcut ? toolbarShortcut : `Default (${DEFAULT_TOOLBAR_SHORTCUT})`
}
readOnly={true}
data-focus-visible="false"
/>
<div className="flex flex-row gap-2">
<Button
color="primary"
onPress={() => {
if (isSaving) {
return
}
if (isRecording) {
setIsRecording(false)
setFeedback(null)
return
}
setFeedback('Press the new shortcut keys…')
setIsRecording(true)
}}
isDisabled={isSaving}
data-focus-visible="false"
>
{isRecording ? 'Cancel recording' : 'Record shortcut'}
</Button>
<Button
variant="flat"
onPress={() => {
setTimeout(() => {
setIsRecording(false)
setFeedback(`Resetting to ${DEFAULT_TOOLBAR_SHORTCUT}`)
updateToolbarShortcutMutation.mutate(DEFAULT_TOOLBAR_SHORTCUT)
}, 100)
}}
isDisabled={isSaving || resolvedShortcut === DEFAULT_TOOLBAR_SHORTCUT}
data-focus-visible="false"
>
Reset to default
</Button>
</div>
{feedback && (
<p className="text-xs text-neutral-400" aria-live="polite">
{feedback}
</p>
)}
</div>
</div>
)
}
+57 -3
View File
@@ -15,14 +15,14 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import {
MessageCircleIcon,
PencilIcon,
PlusIcon,
SendIcon,
SettingsIcon,
Trash2Icon,
TriangleAlertIcon,
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { type ReactNode, useMemo, useState } from 'react'
import {
FREE_MAX_TARGETS,
NOTIFICATION_PROVIDERS,
@@ -97,6 +97,28 @@ export default function NotificationsSection() {
onPress={() => handleAddPress(provider)}
/>
))}
<DummyProviderCard
label="Telegram (bot-less)"
description="Get messages without your own bot"
icon={<SendIcon className="text-sky-500 size-8 shrink-0" />}
onPress={() =>
message(
'Telegram without your own bot is coming in v4. Upgrade to v4 to use it.',
{ title: 'Coming in v4', kind: 'info' }
)
}
/>
<DummyProviderCard
label="WhatsApp"
description="Get messages on WhatsApp"
icon={<MessageCircleIcon className="text-green-500 size-8 shrink-0" />}
onPress={() =>
message(
'WhatsApp notifications are coming in v4. Upgrade to v4 to use them.',
{ title: 'Coming in v4', kind: 'info' }
)
}
/>
</div>
</section>
@@ -153,7 +175,6 @@ function ProviderCard({
data-focus-visible="false"
>
<CardBody className="relative flex flex-row items-center gap-3 px-4">
<PlusIcon className="absolute w-4 h-4 top-3 right-3 text-default-400" />
<ProviderIcon
provider={provider}
className={cn('size-8 shrink-0', providerMeta.accentClass)}
@@ -167,6 +188,39 @@ function ProviderCard({
)
}
// Placeholder cards for providers that don't exist yet — tapping explains they're coming in v4.
function DummyProviderCard({
label,
description,
icon,
onPress,
}: {
label: string
description: string
icon: ReactNode
onPress: () => void
}) {
return (
<Card
shadow="sm"
isPressable={true}
onPress={onPress}
className="h-24 bg-content2"
data-focus-visible="false"
>
<CardBody className="relative flex flex-row items-center gap-3 px-4">
{icon}
<div className="flex flex-col gap-0.5 text-left">
<div className="flex items-center gap-2">
<p className="font-medium">{label}</p>
</div>
<p className="text-small text-default-500">{description}</p>
</div>
</CardBody>
</Card>
)
}
function NotificationTargetCard({
target,
catalog,
+2 -5
View File
@@ -52,10 +52,7 @@ export default function RemotesSection() {
const remotes = useMemo(() => remotesQuery.data ?? [], [remotesQuery.data])
const sortedRemotes = useMemo(
() => [...remotes].sort((a, b) => a.localeCompare(b)),
[remotes]
)
const sortedRemotes = useMemo(() => [...remotes].sort((a, b) => a.localeCompare(b)), [remotes])
const [searchQuery, setSearchQuery] = useState('')
@@ -348,7 +345,7 @@ function RemoteCard({
<div className="flex items-center justify-between h-full">
<div className="flex items-center gap-4">
<img src={imageUrl} className="object-contain ml-2 size-10" alt={remote} />
<p className="font-light text-large">{remote}</p>
<p className="text-large">{remote}</p>
</div>
<div className="flex items-center justify-end gap-4">
{/* Storage info boxes */}
-207
View File
@@ -1,207 +0,0 @@
import { Button, Chip, Input } from '@heroui/react'
import { useMutation } from '@tanstack/react-query'
import { message } from '@tauri-apps/plugin-dialog'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { usePersistedStore } from '../../../store/persisted'
import BaseSection from './BaseSection'
const DEFAULT_TOOLBAR_SHORTCUT = 'CmdOrCtrl+Shift+/'
const KEY_CODE_DISPLAY_MAP: Record<string, string> = {
Backquote: '`',
Minus: '-',
Equal: '=',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Semicolon: ';',
Quote: "'",
Comma: ',',
Period: '.',
Slash: '/',
Space: 'Space',
}
function formatShortcutFromEvent(event: KeyboardEvent): string | null {
const modifiers: string[] = []
if (event.metaKey) {
modifiers.push('Command')
}
if (event.ctrlKey) {
modifiers.push('Ctrl')
}
if (event.altKey) {
modifiers.push('Alt')
}
if (event.shiftKey) {
modifiers.push('Shift')
}
const codeMapped = KEY_CODE_DISPLAY_MAP[event.code]
let key = codeMapped || event.key
if (key === 'Meta') {
key = 'Command'
} else if (key === 'Control') {
key = 'Ctrl'
} else if (key === ' ') {
key = 'Space'
} else if (key && key.length === 1) {
key = key.toUpperCase()
} else if (key) {
key = key.charAt(0).toUpperCase() + key.slice(1)
}
if (!key || ['Shift', 'Ctrl', 'Alt', 'Command'].includes(key)) {
return null
}
const uniqueModifiers = Array.from(new Set(modifiers))
return [...uniqueModifiers, key].join('+')
}
export default function ToolbarSection() {
const toolbarShortcut = usePersistedStore((state) => state.toolbarShortcut)
const setToolbarShortcut = usePersistedStore((state) => state.setToolbarShortcut)
const [isRecording, setIsRecording] = useState(false)
const [isSaving, _] = useState(false)
const [feedback, setFeedback] = useState<string | null>(null)
const resolvedShortcut = useMemo(
() => toolbarShortcut ?? DEFAULT_TOOLBAR_SHORTCUT,
[toolbarShortcut]
)
const updateToolbarShortcutMutation = useMutation({
mutationFn: async (value: string) => {
const normalizedShortcut = value === DEFAULT_TOOLBAR_SHORTCUT ? undefined : value
await setToolbarShortcut(normalizedShortcut)
},
onSuccess: (_, value) => {
startTransition(() => setFeedback(`Toolbar shortcut updated to ${value}`))
},
onError: async (error) => {
console.error('[Toolbar] Failed to update shortcut', error)
setFeedback('Failed to update toolbar shortcut')
await message('Failed to update toolbar shortcut', {
title: 'Toolbar',
kind: 'error',
})
},
})
useEffect(() => {
if (!isRecording) {
return
}
const handleKeyDown = (event: KeyboardEvent) => {
event.preventDefault()
event.stopPropagation()
if (isSaving) {
return
}
const shortcut = formatShortcutFromEvent(event)
if (!shortcut) {
startTransition(() => {
setFeedback('Press a non-modifier key to finish recording…')
})
return
}
startTransition(() => {
setIsRecording(false)
setFeedback(`Saving ${shortcut}`)
})
updateToolbarShortcutMutation.mutate(shortcut)
}
window.addEventListener('keydown', handleKeyDown, { capture: true })
return () => {
window.removeEventListener('keydown', handleKeyDown, { capture: true })
}
}, [isRecording, isSaving, updateToolbarShortcutMutation.mutate])
return (
<BaseSection
header={{
title: 'Toolbar',
endContent: isRecording ? (
<Chip color="primary" size="sm">
Recording
</Chip>
) : null,
}}
>
<div className="flex flex-row justify-center w-full gap-8 px-8">
<div className="flex flex-col items-end flex-grow gap-2">
<h3 className="font-medium">Shortcut</h3>
<p className="text-xs text-neutral-500 text-end">
Press &quot;Record shortcut&quot; and then the desired key combination.
</p>
</div>
<div className="flex flex-col w-3/5 gap-3">
<Input
label="Current"
value={
toolbarShortcut
? toolbarShortcut
: `Default (${DEFAULT_TOOLBAR_SHORTCUT})`
}
readOnly={true}
data-focus-visible="false"
/>
<div className="flex flex-row gap-2">
<Button
color="primary"
onPress={() => {
if (isSaving) {
return
}
if (isRecording) {
setIsRecording(false)
setFeedback(null)
return
}
setFeedback('Press the new shortcut keys…')
setIsRecording(true)
}}
isDisabled={isSaving}
data-focus-visible="false"
>
{isRecording ? 'Cancel recording' : 'Record shortcut'}
</Button>
<Button
variant="flat"
onPress={() => {
setTimeout(() => {
setIsRecording(false)
setFeedback(`Resetting to ${DEFAULT_TOOLBAR_SHORTCUT}`)
updateToolbarShortcutMutation.mutate(DEFAULT_TOOLBAR_SHORTCUT)
}, 100)
}}
isDisabled={isSaving || resolvedShortcut === DEFAULT_TOOLBAR_SHORTCUT}
data-focus-visible="false"
>
Reset to default
</Button>
</div>
{feedback && (
<p className="text-xs text-neutral-400" aria-live="polite">
{feedback}
</p>
)}
</div>
</div>
</BaseSection>
)
}
+44 -57
View File
@@ -11,7 +11,6 @@ import {
EyeIcon,
GlobeIcon,
InfoIcon,
KeyboardIcon,
MedalIcon,
PackageIcon,
SatelliteDishIcon,
@@ -34,7 +33,6 @@ import MobileSection from './MobileSection'
import NotificationsSection from './NotificationsSection'
import ProxySection from './ProxySection'
import RemotesSection from './RemotesSection'
import ToolbarSection from './ToolbarSection'
export default function Settings() {
const [searchParams] = useSearchParams()
@@ -143,9 +141,11 @@ export default function Settings() {
variant="light"
destroyInactiveTabPanel={false}
disableAnimation={true}
className="flex-shrink-0 h-screen px-2 py-4 border-r w-52 dark:bg-transparent bg-content2 border-divider dark:border-neutral-700"
className="flex-shrink-0 h-screen px-2 py-4 overflow-y-auto border-r w-52 dark:bg-transparent bg-content2 border-divider dark:border-neutral-700"
classNames={{
tabList: 'w-full gap-3' + (platform() === 'macos' ? ' pt-6' : ''),
// pb clears the fixed version bar (and the connected-host strip above it) so the
// last tabs stay reachable once the list scrolls.
tabList: 'w-full gap-3 pb-10' + (platform() === 'macos' ? ' pt-6' : ''),
tab: 'h-14 justify-start rounded-large',
tabContent: 'pl-8',
}}
@@ -167,19 +167,6 @@ export default function Settings() {
>
<GeneralSection />
</Tab>
<Tab
key="toolbar"
title={
<div className="flex items-center gap-2">
<KeyboardIcon className="w-5 h-5" />
<span>Toolbar</span>
</div>
}
data-focus-visible="false"
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
>
<ToolbarSection />
</Tab>
<Tab
key="remotes"
title={
@@ -193,6 +180,46 @@ export default function Settings() {
>
<RemotesSection />
</Tab>
<Tab
key="notifications"
title={
<div className="flex items-center gap-2">
<BellIcon className="w-5 h-5" />
<span>Notifications</span>
</div>
}
data-focus-visible="false"
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
>
<NotificationsSection />
</Tab>
<Tab
key="mobile"
title={
<Tooltip
content={
currentHost?.id !== 'local'
? 'Mobile access is only available when using your local machine, not a remote host'
: undefined
}
isDisabled={currentHost?.id === 'local'}
placement="right"
size="lg"
color="foreground"
className="max-w-48"
offset={90}
>
<div className="flex items-center gap-2">
<TabletSmartphoneIcon className="w-5 h-5" />
<span>Mobile</span>
</div>
</Tooltip>
}
data-focus-visible="false"
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
>
<MobileSection />
</Tab>
<Tab
key="hosts"
title={
@@ -289,46 +316,6 @@ export default function Settings() {
>
<ProxySection />
</Tab>
<Tab
key="notifications"
title={
<div className="flex items-center gap-2">
<BellIcon className="w-5 h-5" />
<span>Notifications</span>
</div>
}
data-focus-visible="false"
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
>
<NotificationsSection />
</Tab>
<Tab
key="mobile"
title={
<Tooltip
content={
currentHost?.id !== 'local'
? 'Mobile access is only available when using your local machine, not a remote host'
: undefined
}
isDisabled={currentHost?.id === 'local'}
placement="right"
size="lg"
color="foreground"
className="max-w-48"
offset={90}
>
<div className="flex items-center gap-2">
<TabletSmartphoneIcon className="w-5 h-5" />
<span>Mobile</span>
</div>
</Tooltip>
}
data-focus-visible="false"
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
>
<MobileSection />
</Tab>
<Tab
key="license"
title={
+29 -17
View File
@@ -7,14 +7,13 @@ 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 { useSchedulingAvailable } from '../../lib/scheduler'
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 CronSection from '../components/operation/CronSection'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
@@ -71,7 +70,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. It runs on a system schedule, even when the app is closed.
• Cron — Schedule this sync to run automatically at set intervals. It runs 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.
@@ -115,7 +114,8 @@ export default function Sync() {
const filterGroup = optionGroups.filter
const configGroup = optionGroups.config
const advanced = useAdvancedSchedule()
const [cronExpression, setCronExpression] = useState<string | null>(null)
const schedulingAvailable = useSchedulingAvailable()
const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest])
@@ -139,7 +139,7 @@ export default function Sync() {
return startSync(buildArgs())
},
onSuccess: () => {
if (advanced.cronExpression) {
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
@@ -148,9 +148,7 @@ export default function Sync() {
const scheduleTaskMutation = useScheduleTask({
operation: 'sync',
cronExpression: advanced.cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
cronExpression,
validate: () => {
if (!source || !dest) {
throw new Error('Please select both a source and destination path')
@@ -183,9 +181,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 (advanced.cronExpression) return 'START AND SCHEDULE SYNC'
if (cronExpression) return 'START AND SCHEDULE SYNC'
return 'START SYNC'
}, [startSyncMutation.isPending, source, dest, jsonError, advanced.cronExpression])
}, [startSyncMutation.isPending, source, dest, jsonError, cronExpression])
const buttonIcon = useMemo(() => {
if (startSyncMutation.isPending) return
@@ -226,6 +224,20 @@ export default function Sync() {
/>
),
},
...(schedulingAvailable
? [
{
key: 'cron',
category: 'cron' as const,
children: (
<CronSection
expression={cronExpression}
onChange={setCronExpression}
/>
),
},
]
: []),
{
key: 'config',
category: 'config',
@@ -276,6 +288,8 @@ export default function Sync() {
configFlags,
selectedRemotes,
remotesGroup,
cronExpression,
schedulingAvailable,
]
)
@@ -300,21 +314,21 @@ export default function Sync() {
const handleResetOptions = useCallback(() => {
startTransition(() => {
resetJson()
advanced.reset()
setCronExpression(null)
startSyncMutation.reset()
})
}, [advanced.reset, resetJson, startSyncMutation.reset])
}, [resetJson, startSyncMutation.reset])
const handleResetAll = useCallback(() => {
startTransition(() => {
resetJson()
resetLocks()
advanced.reset()
setCronExpression(null)
setDest(undefined)
setSource(undefined)
startSyncMutation.reset()
})
}, [advanced.reset, resetJson, resetLocks, startSyncMutation.reset])
}, [resetJson, resetLocks, startSyncMutation.reset])
return (
<div className="flex flex-col h-screen gap-10">
@@ -330,8 +344,6 @@ export default function Sync() {
destOptions={DEST_OPTIONS}
/>
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion banner={true} items={accordionItems} />
</OperationWindowContent>
+17 -9
View File
@@ -116,15 +116,23 @@ export default function Toolbar() {
// Eager per-remote capability probes (operations/fsinfo), cached hard. Feeds the synchronous
// engine so cleanup/purge can gate on the authoritative feature set. Unresolved/unreachable
// remotes are simply absent from the map → those actions fall to their generic item.
const fsInfoQueries = useQueries({ queries: remotes.map((remote) => fsInfoQueryOptions(remote)) })
const capabilitiesByRemote = useMemo(() => {
const map: Record<string, RcloneFeatures> = {}
remotes.forEach((remote, i) => {
const features = fsInfoQueries[i]?.data?.Features
if (features) map[remote] = features
})
return map
}, [remotes, fsInfoQueries])
//
// Built via `combine` (not a useMemo over the raw useQueries array) on purpose: react-query runs
// the combined value through replaceEqualDeep, so `capabilitiesByRemote` keeps a STABLE reference
// across renders until the capability data actually changes. A useMemo keyed on the useQueries
// result recomputed every render (that array is new each time), so the engine effect below re-ran
// and called setState on every render — an infinite update loop.
const capabilitiesByRemote = useQueries({
queries: remotes.map((remote) => fsInfoQueryOptions(remote)),
combine: (results) => {
const map: Record<string, RcloneFeatures> = {}
remotes.forEach((remote, i) => {
const features = results[i]?.data?.Features
if (features) map[remote] = features
})
return map
},
})
const [searchString, setSearchString] = useState('')
const [searchStringDebounced] = useDebounce(searchString, 40)