From 367d0b3be5eb58dfb6c7273a73deea9a475bcf2b Mon Sep 17 00:00:00 2001 From: FTCHD <144691102+FTCHD@users.noreply.github.com> Date: Sun, 12 Jul 2026 01:16:46 +0300 Subject: [PATCH] interface adjustments --- lib/format.ts | 43 +- lib/hooks.ts | 10 +- lib/notifications.ts | 9 + lib/scheduler.ts | 11 + meta.json | 2 +- package.json | 11 +- src/components/BinarySelect.tsx | 3 +- src/components/ConfigSelect.tsx | 3 +- src/components/CronEditor.tsx | 5 +- src/components/NotificationTargetDrawer.tsx | 26 ++ src/components/ScheduleEditDrawer.tsx | 402 +++++++++++------- .../operation/AdvancedScheduleSection.tsx | 139 ------ src/components/operation/CronSection.tsx | 27 ++ src/components/operation/OperationFooter.tsx | 27 +- src/components/operation/OptionsAccordion.tsx | 6 +- src/pages/Bisync.tsx | 36 +- src/pages/Copy.tsx | 46 +- src/pages/Delete.tsx | 54 ++- src/pages/Move.tsx | 46 +- src/pages/Purge.tsx | 46 +- src/pages/Schedules.tsx | 122 +----- src/pages/Settings/BinarySection.tsx | 361 ++++++++-------- src/pages/Settings/GeneralSection.tsx | 199 +++++++++ src/pages/Settings/NotificationsSection.tsx | 60 ++- src/pages/Settings/RemotesSection.tsx | 7 +- src/pages/Settings/ToolbarSection.tsx | 207 --------- src/pages/Settings/index.tsx | 101 ++--- src/pages/Sync.tsx | 46 +- src/pages/Toolbar.tsx | 26 +- 29 files changed, 1085 insertions(+), 996 deletions(-) delete mode 100644 src/components/operation/AdvancedScheduleSection.tsx create mode 100644 src/components/operation/CronSection.tsx delete mode 100644 src/pages/Settings/ToolbarSection.tsx diff --git a/lib/format.ts b/lib/format.ts index 83605ba..f2c32c6 100644 --- a/lib/format.ts +++ b/lib/format.ts @@ -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 + // "/...//" (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( diff --git a/lib/hooks.ts b/lib/hooks.ts index fa62851..e1dc176 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -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, } } diff --git a/lib/notifications.ts b/lib/notifications.ts index 18c2c8f..0932e24 100644 --- a/lib/notifications.ts +++ b/lib/notifications.ts @@ -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', diff --git a/lib/scheduler.ts b/lib/scheduler.ts index 58d6abe..7f7425c 100644 --- a/lib/scheduler.ts +++ b/lib/scheduler.ts @@ -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 diff --git a/meta.json b/meta.json index 34753e9..7dc2d28 100644 --- a/meta.json +++ b/meta.json @@ -1,4 +1,4 @@ { "minimumVersion": "3.1.0", - "okVersion": "3.1.0" + "okVersion": "3.7.0" } diff --git a/package.json b/package.json index 94a81b7..696cb9b 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/BinarySelect.tsx b/src/components/BinarySelect.tsx index 0a1c0a8..be92fc0 100644 --- a/src/components/BinarySelect.tsx +++ b/src/components/BinarySelect.tsx @@ -73,7 +73,8 @@ export default function BinarySelect({ return ( { diff --git a/src/components/CronEditor.tsx b/src/components/CronEditor.tsx index 929ae74..1dca9dc 100644 --- a/src/components/CronEditor.tsx +++ b/src/components/CronEditor.tsx @@ -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' } diff --git a/src/components/NotificationTargetDrawer.tsx b/src/components/NotificationTargetDrawer.tsx index 8c84446..2df9efe 100644 --- a/src/components/NotificationTargetDrawer.tsx +++ b/src/components/NotificationTargetDrawer.tsx @@ -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({ )} + {!!providerMeta.helpUrl && ( + + )} + + {isTelegram && ( + + Open your bot in Telegram and tap Start (or send it any + message). Telegram won't let a bot message you until you do. + + )} +

diff --git a/src/components/ScheduleEditDrawer.tsx b/src/components/ScheduleEditDrawer.tsx index d9d8678..98ed067 100644 --- a/src/components/ScheduleEditDrawer.tsx +++ b/src/components/ScheduleEditDrawer.tsx @@ -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()} -

- {selectedTask.name || 'Untitled Schedule'} -

@@ -251,155 +253,214 @@ export default function ScheduleEditDrawer({ )} -
-

Details

-
-
-

- Source -

-

- {buildReadablePath(source, 'long')} -

+
+
+
+

Enabled

- {destination && ( -
-

- Destination -

-

- {buildReadablePath(destination, 'long')} +

+ +
+
+ +
+
+

Name

+
+
+ +
+
+ +
+
+

Source

+
+
+ + {source} + + } + placement="top-start" + color="foreground" + className="max-w-md" + > +

+ {buildReadablePath(source, 'long')}

+
+
+
+ + {destination && ( +
+
+

Destination

- )} -
-
- - - -
-

Execution

-
- - { - setSaveError(null) - setBinaryPath(path) - }} - onError={setSaveError} - /> -
- {configMissing && ( - - The config this task used no longer exists — pick - another one. - +
+ + {destination} + + } + placement="top-start" + color="foreground" + className="max-w-md" + > +

+ {buildReadablePath(destination, 'long')} +

+
+
+
)} - {configPasswordMissing && ( - - 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. - - )} - - Enabled - -
- Run mode - - setRunMode(key as 'system' | 'user') - } - data-focus-visible="false" - > - - - - - {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.'} - + +
+
+

Config

+
+
+ + {configMissing && ( + + The config this task used no longer exists — + pick another one. + + )} + {configPasswordMissing && ( + + 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. + + )} +
- -
- Verbose logging + +
+
+

Binary

+
+
+ { + setSaveError(null) + setBinaryPath(path) + }} + onError={setSaveError} + label="" + /> +
+
+ +
+
+

Run mode

+
+
+ + setRunMode(key as 'system' | 'user') + } + data-focus-visible="false" + > + + + - 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.' : ''}`}
- -
+
- - -
-

- - Upcoming Runs -

- {upcomingRuns.length > 0 ? ( -
- {upcomingRuns.map((run, index) => ( -
- - {index + 1} - - - {format(run, 'EEEE, MMMM d, yyyy')} - - - at - - - {format(run, 'HH:mm')} +
+
+

Logging

+
+
+ +
+ Verbose + + Log individual transfers to the rclone + log
- ))} +
- ) : ( -

- No upcoming runs scheduled (invalid cron expression) -

- )} +
+ +
+
+

Max run time

+
+
+ + hours + + } + 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" + /> +
+
@@ -418,21 +479,37 @@ export default function ScheduleEditDrawer({
-

Advanced

- +

Upcoming Runs

+ {upcomingRuns.length > 0 ? ( +
+
+ {upcomingRuns.slice(0, 5).map((run, index) => ( + + ))} +
+ {upcomingRuns.length > 5 && ( +
+ {upcomingRuns + .slice(5, 10) + .map((run, index) => ( + + ))} +
+ )} +
+ ) : ( +

+ No upcoming runs scheduled (invalid cron expression) +

+ )}
@@ -563,3 +640,16 @@ export default function ScheduleEditDrawer({ ) } + +function UpcomingRunRow({ run, index }: { run: Date; index: number }) { + return ( +
+ + {index + 1} + + {format(run, 'EEEE, MMMM d, yyyy')} + at + {format(run, 'HH:mm')} +
+ ) +} diff --git a/src/components/operation/AdvancedScheduleSection.tsx b/src/components/operation/AdvancedScheduleSection.tsx deleted file mode 100644 index 749e59b..0000000 --- a/src/components/operation/AdvancedScheduleSection.tsx +++ /dev/null @@ -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(null) - const [binaryPath, setBinaryPath] = useState(APP_DEFAULT_BINARY) - const [configId, setConfigId] = useState(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(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 ( -
- - - {expanded && ( -
-
-

Schedule

- -
- -
- - { - setPickerError(null) - advanced.setBinaryPath(path) - }} - onError={setPickerError} - /> -
-

- The config and binary apply to the scheduled task; immediate runs use the - app's active config and binary. -

- {!!pickerError &&

{pickerError}

} - {passwordMissing && ( -

- This config is encrypted with no saved password — scheduled runs will - fail until you save it in Settings → Config. -

- )} -
- )} -
- ) -} diff --git a/src/components/operation/CronSection.tsx b/src/components/operation/CronSection.tsx new file mode 100644 index 0000000..ecf0743 --- /dev/null +++ b/src/components/operation/CronSection.tsx @@ -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 +} diff --git a/src/components/operation/OperationFooter.tsx b/src/components/operation/OperationFooter.tsx index 658ee11..c11605e 100644 --- a/src/components/operation/OperationFooter.tsx +++ b/src/components/operation/OperationFooter.tsx @@ -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({ ) : null} - -
+ {schedulingAvailable ? ( + -
-
+ + ) : null} diff --git a/src/components/operation/OptionsAccordion.tsx b/src/components/operation/OptionsAccordion.tsx index 8c9740c..1cdeee1 100644 --- a/src/components/operation/OptionsAccordion.tsx +++ b/src/components/operation/OptionsAccordion.tsx @@ -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({ diff --git a/src/pages/Bisync.tsx b/src/pages/Bisync.tsx index 2df65dd..1424554 100644 --- a/src/pages/Bisync.tsx +++ b/src/pages/Bisync.tsx @@ -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>({}) - const advanced = useAdvancedSchedule() + const [cronExpression, setCronExpression] = useState(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: ( + + ), + }, + ] + : []), { 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} /> - - diff --git a/src/pages/Copy.tsx b/src/pages/Copy.tsx index acf172c..2da4f10 100644 --- a/src/pages/Copy.tsx +++ b/src/pages/Copy.tsx @@ -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(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: ( + + ), + }, + ] + : []), { 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} /> - - diff --git a/src/pages/Delete.tsx b/src/pages/Delete.tsx index f04efed..ed551af 100644 --- a/src/pages/Delete.tsx +++ b/src/pages/Delete.tsx @@ -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(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: ( + + ), + }, + ] + : []), ], - [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 (
@@ -261,8 +281,6 @@ export default function Delete() { )} - - diff --git a/src/pages/Move.tsx b/src/pages/Move.tsx index 8770591..3a83130 100644 --- a/src/pages/Move.tsx +++ b/src/pages/Move.tsx @@ -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(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: ( + + ), + }, + ] + : []), { 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 (
@@ -314,8 +328,6 @@ export default function Move() { setDestPath={setDest} /> - - diff --git a/src/pages/Purge.tsx b/src/pages/Purge.tsx index ebee4f1..c8a0147 100644 --- a/src/pages/Purge.tsx +++ b/src/pages/Purge.tsx @@ -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(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: ( + + ), + }, + ] + : []), ], - [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 (
@@ -196,8 +208,6 @@ export default function Purge() { showFiles={false} /> - - { - 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 (
@@ -121,20 +88,6 @@ export default function Schedules() { classNames={{ base: 'flex-shrink-0' }} /> )} - {isLocalHost && ( -
- -
- )} {scheduledTasks.map((task) => ( 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({ )}
- - { - 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)} - /> - +

+ {task.name || 'Untitled Schedule'} +

{buildReadablePath(source, 'short')} {'→'}{' '} {'destination' in task.args diff --git a/src/pages/Settings/BinarySection.tsx b/src/pages/Settings/BinarySection.tsx index 7296b06..66f858d 100644 --- a/src/pages/Settings/BinarySection.tsx +++ b/src/pages/Settings/BinarySection.tsx @@ -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 ( -
- {/* ---- Custom binary ---- */} - + {/* ---- Custom binary ---- */} +
+
+

Custom binary

+

+ Point to an rclone binary on your machine. +

+
+
+ +
+
- {/* ---- PATH integration ---- */} - + {/* ---- PATH integration ---- */} +
+
+

Path

+
+
+ +
+
- {/* ---- Auto update ---- */} - + {/* ---- Auto update ---- */} +
+
+

Updates

+
+
+ +
+
- {/* ---- Versions ---- */} -
- {/* System */} - {systemQuery.data && ( - - activateMutation.mutate({ - path: systemQuery.data!, - isSystem: true, - }) - } - /> - )} - - {/* Downloaded (managed) */} - {downloadedVersions.map((v) => { - const isActive = active?.kind === 'managed' && active.version === v.version - return ( + {/* ---- Versions ---- */} +
+
+

Versions

+
+
+
+ {/* System */} + {systemQuery.data && ( 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 ( -
-
- v{r.version} - {isDownloading && ( - - )} -
- -
- ) - })} - - {(downloadedVersions.length > 0 || systemQuery.data) && - availableToDownload.length === 0 && - releasesQuery.isError && ( -
- - Couldn't load available versions (offline or rate-limited). - - -
)} - {releasesQuery.isLoading && downloadedVersions.length === 0 && ( -
- + {/* Downloaded (managed) */} + {downloadedVersions.map((v) => { + const isActive = + active?.kind === 'managed' && active.version === v.version + return ( + 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 ( +
+
+ + v{r.version} + + {isDownloading && ( + + )} +
+ +
+ ) + })} + + {(downloadedVersions.length > 0 || systemQuery.data) && + availableToDownload.length === 0 && + releasesQuery.isError && ( +
+ + Couldn't load available versions (offline or rate-limited). + + +
+ )} + + {releasesQuery.isLoading && downloadedVersions.length === 0 && ( +
+ +
+ )} +
+ + {updateAvailable && ( +
+ + Update available: v{latestVersion} + +
)}
- - {updateAvailable && ( -
- - Update available: v{latestVersion} - - -
- )}
) @@ -341,13 +373,8 @@ function VersionRow({ {warning && {warning}}
{isActive ? ( - } - > - Active + + ACTIVE ) : ( - } - /> - -
+ ) + } + /> {isCustomActive && ( Currently using a custom binary @@ -538,6 +564,9 @@ function PathIntegrationRow({ > Add rclone to PATH + + Lets you call rclone from your terminal. + {isSystemActive && ( The system rclone is already on your PATH. diff --git a/src/pages/Settings/GeneralSection.tsx b/src/pages/Settings/GeneralSection.tsx index 264bccb..d38df55 100644 --- a/src/pages/Settings/GeneralSection.tsx +++ b/src/pages/Settings/GeneralSection.tsx @@ -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 = { + 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() {
+ + {!isFlathub && (
@@ -336,3 +393,145 @@ export default function GeneralSection() { ) } + +// 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(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 ( +
+
+
+

Toolbar Shortcut

+ {isRecording && ( + + Recording + + )} +
+

+ Press "Record shortcut" and then the desired key combination. +

+
+ +
+ + +
+ + +
+ + {feedback && ( +

+ {feedback} +

+ )} +
+
+ ) +} diff --git a/src/pages/Settings/NotificationsSection.tsx b/src/pages/Settings/NotificationsSection.tsx index ba421a9..30e5e59 100644 --- a/src/pages/Settings/NotificationsSection.tsx +++ b/src/pages/Settings/NotificationsSection.tsx @@ -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)} /> ))} + } + onPress={() => + message( + 'Telegram without your own bot is coming in v4. Upgrade to v4 to use it.', + { title: 'Coming in v4', kind: 'info' } + ) + } + /> + } + onPress={() => + message( + 'WhatsApp notifications are coming in v4. Upgrade to v4 to use them.', + { title: 'Coming in v4', kind: 'info' } + ) + } + />
@@ -153,7 +175,6 @@ function ProviderCard({ data-focus-visible="false" > - void +}) { + return ( + + + {icon} +
+
+

{label}

+
+

{description}

+
+
+
+ ) +} + function NotificationTargetCard({ target, catalog, diff --git a/src/pages/Settings/RemotesSection.tsx b/src/pages/Settings/RemotesSection.tsx index cc308e6..5f84319 100644 --- a/src/pages/Settings/RemotesSection.tsx +++ b/src/pages/Settings/RemotesSection.tsx @@ -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({
{remote} -

{remote}

+

{remote}

{/* Storage info boxes */} diff --git a/src/pages/Settings/ToolbarSection.tsx b/src/pages/Settings/ToolbarSection.tsx deleted file mode 100644 index cf84abb..0000000 --- a/src/pages/Settings/ToolbarSection.tsx +++ /dev/null @@ -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 = { - 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(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 ( - - Recording - - ) : null, - }} - > -
-
-

Shortcut

-

- Press "Record shortcut" and then the desired key combination. -

-
- -
- - -
- - -
- - {feedback && ( -

- {feedback} -

- )} -
-
-
- ) -} diff --git a/src/pages/Settings/index.tsx b/src/pages/Settings/index.tsx index ae4b5f2..36cae86 100644 --- a/src/pages/Settings/index.tsx +++ b/src/pages/Settings/index.tsx @@ -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() { > - - - Toolbar -
- } - data-focus-visible="false" - className="w-full max-h-screen p-0 overflow-scroll overscroll-none" - > - - + + + Notifications +
+ } + data-focus-visible="false" + className="w-full max-h-screen p-0 overflow-scroll overscroll-none" + > + + + +
+ + Mobile +
+ + } + data-focus-visible="false" + className="w-full max-h-screen p-0 overflow-scroll overscroll-none" + > + +
- - - Notifications - - } - data-focus-visible="false" - className="w-full max-h-screen p-0 overflow-scroll overscroll-none" - > - - - -
- - Mobile -
- - } - data-focus-visible="false" - className="w-full max-h-screen p-0 overflow-scroll overscroll-none" - > - -
(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: ( + + ), + }, + ] + : []), { 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 (
@@ -330,8 +344,6 @@ export default function Sync() { destOptions={DEST_OPTIONS} /> - - diff --git a/src/pages/Toolbar.tsx b/src/pages/Toolbar.tsx index 7ef7d95..8928d15 100644 --- a/src/pages/Toolbar.tsx +++ b/src/pages/Toolbar.tsx @@ -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 = {} - 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 = {} + 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)