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 = /^[a-zA-Z]:([/\\]|$)/
const RE_WINDOWS_DRIVE_WITH_SLASH = /^([a-zA-Z]:)\/?/ const RE_WINDOWS_DRIVE_WITH_SLASH = /^([a-zA-Z]:)\/?/
const RE_LOCAL_WINDOWS_PATH = /^:local:([a-zA-Z]:\/?.*)$/ const RE_LOCAL_WINDOWS_PATH = /^:local:([a-zA-Z]:\/?.*)$/
const RE_LOCAL_PREFIX = /^:local:/
const RE_PATH_SEPARATOR = /[/\\]/ const RE_PATH_SEPARATOR = /[/\\]/
export function formatBytes(bytes: number) { export function formatBytes(bytes: number) {
@@ -48,21 +47,45 @@ export function getRemoteName(path?: string) {
} }
export function buildReadablePath(path: string, type: 'short' | 'long' = 'long') { export function buildReadablePath(path: string, type: 'short' | 'long' = 'long') {
console.log('[buildReadablePath] path', path)
console.log('[buildReadablePath] sep()', sep())
if (!path) { if (!path) {
return '' return ''
} }
const lastSegment = path.split(RE_PATH_SEPARATOR).filter(Boolean).pop()
console.log('[buildReadablePath] lastSegment', lastSegment)
if (type === 'short') { 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( export function buildReadablePathMultiple(
+7 -3
View File
@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type { RcloneFeatures, RcloneFsInfo } from '../types/rclone' import type { RcloneFeatures, RcloneFsInfo } from '../types/rclone'
import { UserCancelledError } from './errors'
import { sortByName } from './flags' import { sortByName } from './flags'
import rclone from './rclone/client' import rclone from './rclone/client'
import { SERVE_TYPES } from './rclone/constants' import { SERVE_TYPES } from './rclone/constants'
@@ -47,11 +48,14 @@ export function useRemoteConfig(remote: string | undefined | null) {
export function fsInfoQueryOptions(remote: string | undefined | null) { export function fsInfoQueryOptions(remote: string | undefined | null) {
return { return {
queryKey: ['remote', remote, 'fsinfo'] as const, queryKey: ['remote', remote, 'fsinfo'] as const,
queryFn: () => queryFn: () => rclone('/operations/fsinfo', { params: { query: { fs: `${remote}:` } } }),
rclone('/operations/fsinfo', { params: { query: { fs: `${remote}:` } } }),
enabled: !!remote && remote !== 'UI_LOCAL_FS' && remote !== 'UI_FAVORITES', enabled: !!remote && remote !== 'UI_LOCAL_FS' && remote !== 'UI_FAVORITES',
staleTime: 1000 * 60 * 60 * 24, 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 description: string
urlPlaceholder: string urlPlaceholder: string
accentClass: string accentClass: string
// Official docs page for obtaining the webhook/bot token, shown as a button in the drawer.
helpUrl?: string
helpLabel?: string
} }
> = { > = {
discord: { discord: {
@@ -194,6 +197,8 @@ export const NOTIFICATION_PROVIDERS: Record<
description: 'Post to a Discord channel', description: 'Post to a Discord channel',
urlPlaceholder: 'https://discord.com/api/webhooks/1234567890/AbCdEf...', urlPlaceholder: 'https://discord.com/api/webhooks/1234567890/AbCdEf...',
accentClass: 'text-indigo-500', accentClass: 'text-indigo-500',
helpUrl: 'https://support.discord.com/hc/en-us/articles/228383668-Intro-to-Webhooks',
helpLabel: 'How to create a webhook',
}, },
slack: { slack: {
label: 'Slack', label: 'Slack',
@@ -201,6 +206,8 @@ export const NOTIFICATION_PROVIDERS: Record<
description: 'Post to a Slack channel', description: 'Post to a Slack channel',
urlPlaceholder: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX', urlPlaceholder: 'https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXX',
accentClass: 'text-emerald-500', accentClass: 'text-emerald-500',
helpUrl: 'https://api.slack.com/messaging/webhooks',
helpLabel: 'How to create a webhook',
}, },
telegram: { telegram: {
label: 'Telegram', label: 'Telegram',
@@ -208,6 +215,8 @@ export const NOTIFICATION_PROVIDERS: Record<
description: 'Message a chat via your bot', description: 'Message a chat via your bot',
urlPlaceholder: 'https://api.telegram.org/bot123456:ABC-DEF...', urlPlaceholder: 'https://api.telegram.org/bot123456:ABC-DEF...',
accentClass: 'text-sky-500', 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: { webhook: {
label: '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 { export interface CronValidation {
valid: boolean valid: boolean
error?: string error?: string
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"minimumVersion": "3.1.0", "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" "url": "https://github.com/rclone-ui/rclone-ui"
}, },
"homepage": "https://rcloneui.com", "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", "license": "Apache-2.0",
"scripts": { "scripts": {
"dev": "node scripts/buildExternal.js && vite", "dev": "node scripts/buildExternal.js && vite",
+2 -1
View File
@@ -73,7 +73,8 @@ export default function BinarySelect({
return ( return (
<Select <Select
label={label} label={label || undefined}
aria-label={label ? undefined : 'rclone binary'}
labelPlacement="outside" labelPlacement="outside"
selectedKeys={[isCustomBinary ? CUSTOM_BINARY : value]} selectedKeys={[isCustomBinary ? CUSTOM_BINARY : value]}
onSelectionChange={(keys) => { onSelectionChange={(keys) => {
+2 -1
View File
@@ -18,7 +18,8 @@ export default function ConfigSelect({
}) { }) {
return ( return (
<Select <Select
label={label} label={label || undefined}
aria-label={label ? undefined : 'Config file'}
labelPlacement="outside" labelPlacement="outside"
selectedKeys={value ? [value] : []} selectedKeys={value ? [value] : []}
onSelectionChange={(keys) => { onSelectionChange={(keys) => {
+3 -2
View File
@@ -29,11 +29,12 @@ export default function CronEditor({ expression, onChange, error }: CronEditorPr
) )
const readableDescription = useMemo(() => { 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 let description: string
try { try {
description = cronstrue.toString(cronExpression) 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 { } catch {
description = 'Invalid cron expression' description = 'Invalid cron expression'
} }
@@ -1,4 +1,5 @@
import { import {
Alert,
Button, Button,
Checkbox, Checkbox,
CheckboxGroup, CheckboxGroup,
@@ -13,7 +14,9 @@ import {
} from '@heroui/react' } from '@heroui/react'
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { message } from '@tauri-apps/plugin-dialog' import { message } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import { ExternalLinkIcon } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { import {
NOTIFICATION_PROVIDERS, NOTIFICATION_PROVIDERS,
@@ -304,6 +307,29 @@ export default function NotificationTargetDrawer({
)} )}
</section> </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"> <section className="flex flex-col gap-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<p className="text-sm font-semibold uppercase text-default-500"> <p className="text-sm font-semibold uppercase text-default-500">
+196 -106
View File
@@ -13,12 +13,12 @@ import {
Switch, Switch,
Tab, Tab,
Tabs, Tabs,
Tooltip,
cn, cn,
} from '@heroui/react' } from '@heroui/react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import { format, formatDistance } from 'date-fns' import { format, formatDistance } from 'date-fns'
import { CalendarClockIcon } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { formatErrorMessage } from '../../lib/errors' import { formatErrorMessage } from '../../lib/errors'
import { buildReadablePath } from '../../lib/format' import { buildReadablePath } from '../../lib/format'
@@ -49,6 +49,7 @@ export default function ScheduleEditDrawer({
const queryClient = useQueryClient() const queryClient = useQueryClient()
const configFiles = useHostStore((state) => state.configFiles) const configFiles = useHostStore((state) => state.configFiles)
const [name, setName] = useState(selectedTask.name ?? '')
const [cronExpression, setCronExpression] = useState(selectedTask.cron) const [cronExpression, setCronExpression] = useState(selectedTask.cron)
const [configId, setConfigId] = useState(selectedTask.configId) const [configId, setConfigId] = useState(selectedTask.configId)
const [binaryPath, setBinaryPath] = useState(selectedTask.binaryPath) const [binaryPath, setBinaryPath] = useState(selectedTask.binaryPath)
@@ -63,6 +64,7 @@ export default function ScheduleEditDrawer({
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setName(selectedTask.name ?? '')
setCronExpression(selectedTask.cron) setCronExpression(selectedTask.cron)
setConfigId(selectedTask.configId) setConfigId(selectedTask.configId)
setBinaryPath(selectedTask.binaryPath) setBinaryPath(selectedTask.binaryPath)
@@ -141,6 +143,7 @@ export default function ScheduleEditDrawer({
const hasChanges = useMemo( const hasChanges = useMemo(
() => () =>
name !== (selectedTask.name ?? '') ||
cronExpression !== selectedTask.cron || cronExpression !== selectedTask.cron ||
configId !== selectedTask.configId || configId !== selectedTask.configId ||
binaryPath !== selectedTask.binaryPath || binaryPath !== selectedTask.binaryPath ||
@@ -149,6 +152,7 @@ export default function ScheduleEditDrawer({
runMode !== (selectedTask.runMode ?? 'user') || runMode !== (selectedTask.runMode ?? 'user') ||
maxRunHoursNumber !== (selectedTask.maxRunHours ?? DEFAULT_MAX_RUN_HOURS), maxRunHoursNumber !== (selectedTask.maxRunHours ?? DEFAULT_MAX_RUN_HOURS),
[ [
name,
cronExpression, cronExpression,
configId, configId,
binaryPath, binaryPath,
@@ -171,6 +175,7 @@ export default function ScheduleEditDrawer({
mutationFn: async () => { mutationFn: async () => {
setSaveError(null) setSaveError(null)
await schedulerUpdateTask(selectedTask.id, { await schedulerUpdateTask(selectedTask.id, {
name: name.trim(),
cron: cronExpression, cron: cronExpression,
configId, configId,
binaryPath, binaryPath,
@@ -222,9 +227,6 @@ export default function ScheduleEditDrawer({
> >
{selectedTask.operation.toUpperCase()} {selectedTask.operation.toUpperCase()}
</Chip> </Chip>
<p className="text-small text-foreground-500 line-clamp-1">
{selectedTask.name || 'Untitled Schedule'}
</p>
</div> </div>
<Divider /> <Divider />
</div> </div>
@@ -251,58 +253,108 @@ export default function ScheduleEditDrawer({
</Alert> </Alert>
)} )}
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-6">
<h3 className="text-lg font-medium">Details</h3> <div className="flex flex-row justify-center w-full gap-8">
<div className="grid grid-cols-2 gap-4"> <div className="flex flex-col items-end flex-1 gap-2">
<div className="flex flex-col gap-1"> <h4 className="font-medium">Enabled</h4>
<p className="text-sm text-foreground-500"> </div>
Source <div className="flex flex-col w-3/5 gap-3">
</p> <Switch
<p className="font-mono text-sm"> 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')} {buildReadablePath(source, 'long')}
</p> </p>
</Tooltip>
</div> </div>
</div>
{destination && ( {destination && (
<div className="flex flex-col gap-1"> <div className="flex flex-row justify-center w-full gap-8">
<p className="text-sm text-foreground-500"> <div className="flex flex-col items-end flex-1 gap-2">
Destination <h4 className="font-medium">Destination</h4>
</p> </div>
<p className="font-mono text-sm"> <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')} {buildReadablePath(destination, 'long')}
</p> </p>
</Tooltip>
</div>
</div> </div>
)} )}
</div>
</div>
<Divider /> <div className="flex flex-row justify-center w-full gap-8">
<div className="flex flex-col items-end flex-1 gap-2">
<div className="flex flex-col gap-3"> <h4 className="font-medium">Config</h4>
<h3 className="text-lg font-medium">Execution</h3> </div>
<div className="grid grid-cols-2 gap-4"> <div className="flex flex-col w-3/5 gap-3">
<ConfigSelect <ConfigSelect
configFiles={configFiles} configFiles={configFiles}
value={configId} value={configId}
onChange={setConfigId} onChange={setConfigId}
label=""
placeholder={ placeholder={
configMissing configMissing
? 'Config no longer exists' ? 'Config no longer exists'
: undefined : undefined
} }
/> />
<BinarySelect
value={binaryPath}
onChange={(path) => {
setSaveError(null)
setBinaryPath(path)
}}
onError={setSaveError}
/>
</div>
{configMissing && ( {configMissing && (
<Alert color="danger" variant="faded" title=""> <Alert color="danger" variant="faded" title="">
The config this task used no longer exists pick The config this task used no longer exists
another one. pick another one.
</Alert> </Alert>
)} )}
{configPasswordMissing && ( {configPasswordMissing && (
@@ -311,23 +363,38 @@ export default function ScheduleEditDrawer({
variant="faded" variant="faded"
title="Encrypted config without a saved password" title="Encrypted config without a saved password"
> >
This config is encrypted and has no saved password This config is encrypted and has no saved
or password command. The scheduled runner cannot password or password command. The scheduled
prompt for it, so runs will fail until you save the runner cannot prompt for it, so runs will
password in Settings Config. fail until you save the password in Settings
Config.
</Alert> </Alert>
)} )}
<Switch </div>
size="sm" </div>
color="primary"
isSelected={isEnabled} <div className="flex flex-row justify-center w-full gap-8">
onValueChange={setIsEnabled} <div className="flex flex-col items-end flex-1 gap-2">
data-focus-visible="false" <h4 className="font-medium">Binary</h4>
> </div>
Enabled <div className="flex flex-col w-3/5 gap-3">
</Switch> <BinarySelect
<div className="flex flex-col gap-1"> value={binaryPath}
<span className="text-small">Run mode</span> 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 <Tabs
size="sm" size="sm"
selectedKey={runMode} selectedKey={runMode}
@@ -341,10 +408,17 @@ export default function ScheduleEditDrawer({
</Tabs> </Tabs>
<span className="text-tiny text-default-400"> <span className="text-tiny text-default-400">
{runMode === 'user' {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 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, and protected folders on macOS need Full Disk Access for cron.'} : `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> </span>
</div> </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">Logging</h4>
</div>
<div className="flex flex-col w-3/5 gap-3">
<Switch <Switch
size="sm" size="sm"
color="primary" color="primary"
@@ -353,53 +427,40 @@ export default function ScheduleEditDrawer({
data-focus-visible="false" data-focus-visible="false"
> >
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-small">Verbose logging</span> <span className="text-small">Verbose</span>
<span className="text-tiny text-default-400"> <span className="text-tiny text-default-400">
Log individual transfers to the rclone log Log individual transfers to the rclone
(grows faster) log
</span> </span>
</div> </div>
</Switch> </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')}
</span>
</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">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>
) : (
<p className="text-sm text-foreground-500">
No upcoming runs scheduled (invalid cron expression)
</p>
)}
</div> </div>
<Divider /> <Divider />
@@ -418,21 +479,37 @@ export default function ScheduleEditDrawer({
<Divider /> <Divider />
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<h3 className="text-lg font-medium">Advanced</h3> <h3 className="text-lg font-medium">Upcoming Runs</h3>
<Input {upcomingRuns.length > 0 ? (
type="number" <div className="flex flex-row justify-between">
label="Max run time (hours)" <div className="flex flex-col gap-2">
labelPlacement="outside" {upcomingRuns.slice(0, 5).map((run, index) => (
min={1} <UpcomingRunRow
max={MAX_RUN_HOURS_LIMIT} key={run.toISOString()}
value={maxRunHours} run={run}
onValueChange={setMaxRunHours} index={index}
isInvalid={maxRunHoursInvalid}
errorMessage={`Enter a whole number of hours between 1 and ${MAX_RUN_HOURS_LIMIT}`}
description="A run still going after this long is stopped and marked failed."
className="max-w-64"
data-focus-visible="false"
/> />
))}
</div>
{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> </div>
<Divider /> <Divider />
@@ -563,3 +640,16 @@ export default function ScheduleEditDrawer({
</Drawer> </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} />
}
+6 -19
View File
@@ -11,10 +11,8 @@ import { platform } from '@tauri-apps/plugin-os'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { ClockIcon, EyeIcon } from 'lucide-react' import { ClockIcon, EyeIcon } from 'lucide-react'
import { type ComponentProps, type ReactNode, useCallback, useMemo } from 'react' import { type ComponentProps, type ReactNode, useCallback, useMemo } from 'react'
import { LOCAL_HOST_ID } from '../../../lib/hosts' import { useSchedulingAvailable } from '../../../lib/scheduler'
import { useSchedulerSupported } from '../../../lib/scheduler'
import { openWindow } from '../../../lib/window' import { openWindow } from '../../../lib/window'
import { usePersistedStore } from '../../../store/persisted'
import type { Template } from '../../../types/template' import type { Template } from '../../../types/template'
import CommandInfoButton from '../CommandInfoButton' import CommandInfoButton from '../CommandInfoButton'
import CommandsDropdown from '../CommandsDropdown' import CommandsDropdown from '../CommandsDropdown'
@@ -76,18 +74,8 @@ export default function OperationFooter({
const dropdownShadow = useMemo(() => (platform() === 'windows' ? 'none' : undefined), []) const dropdownShadow = useMemo(() => (platform() === 'windows' ? 'none' : undefined), [])
// Scheduling is OS-native and local-host-only; hide the affordance where it can't work // Scheduling is OS-native and local-host-only; hide the affordance where it can't work
// (sandboxed installs, remote hosts). // (sandboxed installs, remote hosts) — mirrors the Cron options section on the operation pages.
const currentHostId = usePersistedStore((state) => state.currentHostId) ?? LOCAL_HOST_ID const schedulingAvailable = useSchedulingAvailable()
const supportQuery = useSchedulerSupported()
// Treat unresolved support as unavailable (matches AdvancedScheduleSection and Schedules.tsx)
// — otherwise the Schedule button is enabled while the only cron-entry UI is still hidden.
const schedulingAvailable =
currentHostId === LOCAL_HOST_ID && (supportQuery.data?.supported ?? false)
const scheduleTooltip = schedulingAvailable
? 'Schedule task'
: currentHostId !== LOCAL_HOST_ID
? 'Scheduling is only available on your local machine'
: (supportQuery.data?.reason ?? 'Scheduling is not available on this system')
const handleStartPress = useCallback(() => { const handleStartPress = useCallback(() => {
setTimeout(() => onStart(), 100) setTimeout(() => onStart(), 100)
@@ -211,20 +199,19 @@ export default function OperationFooter({
</Button> </Button>
</Tooltip> </Tooltip>
) : null} ) : null}
<Tooltip content={scheduleTooltip} placement="top" size="lg" color="foreground"> {schedulingAvailable ? (
<div> <Tooltip content="Schedule task" placement="top" size="lg" color="foreground">
<Button <Button
size="lg" size="lg"
type="button" type="button"
color="primary" color="primary"
isIconOnly={true} isIconOnly={true}
isDisabled={!schedulingAvailable}
onPress={handleSchedulePress} onPress={handleSchedulePress}
> >
<ClockIcon className="size-6" /> <ClockIcon className="size-6" />
</Button> </Button>
</div>
</Tooltip> </Tooltip>
) : null}
<CommandInfoButton content={helpContent} /> <CommandInfoButton content={helpContent} />
<CommandsDropdown currentCommand={operation} /> <CommandsDropdown currentCommand={operation} />
</ButtonGroup> </ButtonGroup>
@@ -1,6 +1,7 @@
import { Accordion, AccordionItem, Avatar } from '@heroui/react' import { Accordion, AccordionItem, Avatar } from '@heroui/react'
import { import {
ChevronDownIcon, ChevronDownIcon,
ClockIcon,
CopyIcon, CopyIcon,
DiamondPercentIcon, DiamondPercentIcon,
FilterIcon, FilterIcon,
@@ -14,7 +15,7 @@ import { usePersistedStore } from '../../../store/persisted'
// Avatar/indicator/title per option category — exactly what each page's accordion rendered. // Avatar/indicator/title per option category — exactly what each page's accordion rendered.
export const CATEGORY_META: Record< export const CATEGORY_META: Record<
'copy' | 'sync' | 'move' | 'bisync' | 'filters' | 'config' | 'remotes', 'copy' | 'sync' | 'move' | 'bisync' | 'filters' | 'cron' | 'config' | 'remotes',
{ {
title: string title: string
icon: ComponentType<{ className?: string }> icon: ComponentType<{ className?: string }>
@@ -33,6 +34,7 @@ export const CATEGORY_META: Record<
avatarIconClassName: 'text-success-foreground', avatarIconClassName: 'text-success-foreground',
}, },
filters: { title: 'Filters', icon: FilterIcon, avatarColor: 'danger' }, filters: { title: 'Filters', icon: FilterIcon, avatarColor: 'danger' },
cron: { title: 'Cron', icon: ClockIcon, avatarColor: 'warning' },
config: { title: 'Config', icon: WrenchIcon, avatarColor: 'default' }, config: { title: 'Config', icon: WrenchIcon, avatarColor: 'default' },
remotes: { title: 'Remotes', icon: ServerIcon, avatarClassName: 'bg-fuchsia-500' }, 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, * The option-group accordion shared by the operation pages: item scaffolding (Avatar,
* indicator, title) comes from CATEGORY_META; each item's content (OptionsSection / * 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. * relative div with the ShowMoreOptionsBanner (Copy/Sync/Move); Bisync/Delete/Purge omit it.
*/ */
export default function OptionsAccordion({ export default function OptionsAccordion({
+24 -12
View File
@@ -8,14 +8,13 @@ import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks' import { useFlags } from '../../lib/hooks'
import { startBisync } from '../../lib/rclone/api' import { startBisync } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { useSchedulingAvailable } from '../../lib/scheduler'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { PathFinder } from '../components/PathFinder' import { PathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection' import RemoteOptionsSection from '../components/RemoteOptionsSection'
import AdvancedScheduleSection, { import CronSection from '../components/operation/CronSection'
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter' import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, { import OptionsAccordion, {
type OptionsAccordionItemDef, type OptionsAccordionItemDef,
@@ -103,7 +102,8 @@ export default function Bisync() {
const [outerBisyncOptions, setOuterBisyncOptions] = useState<Record<string, boolean>>({}) 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]) const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest])
@@ -141,7 +141,7 @@ export default function Bisync() {
return startBisync(buildStartArgs()) return startBisync(buildStartArgs())
}, },
onSuccess: () => { onSuccess: () => {
if (advanced.cronExpression) { if (cronExpression) {
scheduleTaskMutation.mutate() scheduleTaskMutation.mutate()
} }
}, },
@@ -153,9 +153,7 @@ export default function Bisync() {
const scheduleTaskMutation = useScheduleTask({ const scheduleTaskMutation = useScheduleTask({
operation: 'bisync', operation: 'bisync',
cronExpression: advanced.cronExpression, cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => { validate: () => {
if (!source || !dest) { if (!source || !dest) {
throw new Error('Please select both a source and destination path') 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 (!dest) return 'Please select a destination path'
if (source === dest) return 'Source and destination cannot be the same' if (source === dest) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' 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' return 'START BISYNC'
}, [startBisyncMutation.isPending, source, dest, jsonError, advanced.cronExpression]) }, [startBisyncMutation.isPending, source, dest, jsonError, cronExpression])
const buttonIcon = useMemo(() => { const buttonIcon = useMemo(() => {
if (startBisyncMutation.isPending) return 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', key: 'config',
category: 'config', category: 'config',
@@ -371,6 +383,8 @@ export default function Bisync() {
configFlags, configFlags,
selectedRemotes, selectedRemotes,
remotesGroup, remotesGroup,
cronExpression,
schedulingAvailable,
] ]
) )
@@ -424,8 +438,6 @@ export default function Bisync() {
setDestPath={setDest} setDestPath={setDest}
/> />
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion items={accordionItems} /> <OptionsAccordion items={accordionItems} />
</OperationWindowContent> </OperationWindowContent>
+29 -17
View File
@@ -7,15 +7,14 @@ import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks' import { useFlags } from '../../lib/hooks'
import { startCopy, startDryRun } from '../../lib/rclone/api' import { startCopy, startDryRun } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { useSchedulingAvailable } from '../../lib/scheduler'
import { usePersistedStore } from '../../store/persisted' import { usePersistedStore } from '../../store/persisted'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { MultiPathFinder } from '../components/PathFinder' import { MultiPathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection' import RemoteOptionsSection from '../components/RemoteOptionsSection'
import AdvancedScheduleSection, { import CronSection from '../components/operation/CronSection'
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter' import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, { import OptionsAccordion, {
type OptionsAccordionItemDef, 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). • 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. • 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 filterGroup = optionGroups.filter
const configGroup = optionGroups.config const configGroup = optionGroups.config
const advanced = useAdvancedSchedule() const [cronExpression, setCronExpression] = useState<string | null>(null)
const schedulingAvailable = useSchedulingAvailable()
const selectedRemotes = useMemo( const selectedRemotes = useMemo(
() => [...(sources || []), dest].filter(Boolean), () => [...(sources || []), dest].filter(Boolean),
@@ -113,7 +113,7 @@ export default function Copy() {
return startCopy(buildArgs()) return startCopy(buildArgs())
}, },
onSuccess: () => { onSuccess: () => {
if (advanced.cronExpression) { if (cronExpression) {
scheduleTaskMutation.mutate() scheduleTaskMutation.mutate()
} }
}, },
@@ -125,9 +125,7 @@ export default function Copy() {
const scheduleTaskMutation = useScheduleTask({ const scheduleTaskMutation = useScheduleTask({
operation: 'copy', operation: 'copy',
cronExpression: advanced.cronExpression, cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => { validate: () => {
if (!sources || sources.length === 0 || !dest) { if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path') 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 (!dest) return 'Please select a destination path'
if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same' if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' 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' return 'START COPY'
}, [startCopyMutation.isPending, sources, dest, jsonError, advanced.cronExpression]) }, [startCopyMutation.isPending, sources, dest, jsonError, cronExpression])
const buttonIcon = useMemo(() => { const buttonIcon = useMemo(() => {
if (startCopyMutation.isPending) return 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', key: 'config',
category: 'config', category: 'config',
@@ -258,6 +270,8 @@ export default function Copy() {
filterFlags, filterFlags,
configFlags, configFlags,
selectedRemotes, selectedRemotes,
cronExpression,
schedulingAvailable,
] ]
) )
@@ -282,21 +296,21 @@ export default function Copy() {
const handleResetOptions = useCallback(() => { const handleResetOptions = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
advanced.reset() setCronExpression(null)
startCopyMutation.reset() startCopyMutation.reset()
}) })
}, [advanced.reset, resetJson, startCopyMutation.reset]) }, [resetJson, startCopyMutation.reset])
const handleResetAll = useCallback(() => { const handleResetAll = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
resetLocks() resetLocks()
advanced.reset() setCronExpression(null)
setSources(undefined) setSources(undefined)
setDest(undefined) setDest(undefined)
startCopyMutation.reset() startCopyMutation.reset()
}) })
}, [advanced.reset, resetJson, resetLocks, startCopyMutation.reset]) }, [resetJson, resetLocks, startCopyMutation.reset])
useEffect(() => { useEffect(() => {
console.log('[Copy] remoteOptions', remotesGroup.options) console.log('[Copy] remoteOptions', remotesGroup.options)
@@ -315,8 +329,6 @@ export default function Copy() {
setDestPath={setDest} setDestPath={setDest}
/> />
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion banner={true} items={accordionItems} /> <OptionsAccordion banner={true} items={accordionItems} />
</OperationWindowContent> </OperationWindowContent>
+36 -18
View File
@@ -10,13 +10,12 @@ import { hasFeature, useFlags, useFsInfo } from '../../lib/hooks'
import { notify } from '../../lib/notifications' import { notify } from '../../lib/notifications'
import { startDelete, startDryRun } from '../../lib/rclone/api' import { startDelete, startDryRun } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { useSchedulingAvailable } from '../../lib/scheduler'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { PathField } from '../components/PathFinder' import { PathField } from '../components/PathFinder'
import AdvancedScheduleSection, { import CronSection from '../components/operation/CronSection'
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter' import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, { import OptionsAccordion, {
type OptionsAccordionItemDef, 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. • 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) 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. 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 searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
) )
const advanced = useAdvancedSchedule() const [cronExpression, setCronExpression] = useState<string | null>(null)
const schedulingAvailable = useSchedulingAvailable()
const { const {
jsonError, jsonError,
@@ -108,7 +108,7 @@ export default function Delete() {
title: 'Success', title: 'Success',
body: 'Delete task started', body: 'Delete task started',
}) })
if (advanced.cronExpression) { if (cronExpression) {
scheduleTaskMutation.mutate() scheduleTaskMutation.mutate()
} }
}, },
@@ -119,9 +119,7 @@ export default function Delete() {
const scheduleTaskMutation = useScheduleTask({ const scheduleTaskMutation = useScheduleTask({
operation: 'delete', operation: 'delete',
cronExpression: advanced.cronExpression, cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => { validate: () => {
if (!sourceFs) { if (!sourceFs) {
throw new Error('Please select a source path to delete') throw new Error('Please select a source path to delete')
@@ -149,9 +147,9 @@ export default function Delete() {
if (startDeleteMutation.isPending) return 'STARTING...' if (startDeleteMutation.isPending) return 'STARTING...'
if (!sourceFs || sourceFs.length === 0) return 'Please select a source path' if (!sourceFs || sourceFs.length === 0) return 'Please select a source path'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' 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' return 'START DELETE'
}, [startDeleteMutation.isPending, sourceFs, jsonError, advanced.cronExpression]) }, [startDeleteMutation.isPending, sourceFs, jsonError, cronExpression])
const buttonIcon = useMemo(() => { const buttonIcon = useMemo(() => {
if (startDeleteMutation.isPending) return 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( const handleStart = useCallback(
@@ -219,20 +239,20 @@ export default function Delete() {
const handleResetOptions = useCallback(() => { const handleResetOptions = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
advanced.reset() setCronExpression(null)
startDeleteMutation.reset() startDeleteMutation.reset()
}) })
}, [advanced.reset, resetJson, startDeleteMutation.reset]) }, [resetJson, startDeleteMutation.reset])
const handleResetAll = useCallback(() => { const handleResetAll = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
resetLocks() resetLocks()
advanced.reset() setCronExpression(null)
setSourceFs(undefined) setSourceFs(undefined)
startDeleteMutation.reset() startDeleteMutation.reset()
}) })
}, [advanced.reset, resetJson, resetLocks, startDeleteMutation.reset]) }, [resetJson, resetLocks, startDeleteMutation.reset])
return ( return (
<div className="flex flex-col h-screen gap-10"> <div className="flex flex-col h-screen gap-10">
@@ -261,8 +281,6 @@ export default function Delete() {
</Alert> </Alert>
)} )}
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion items={accordionItems} /> <OptionsAccordion items={accordionItems} />
</OperationWindowContent> </OperationWindowContent>
+29 -17
View File
@@ -7,15 +7,14 @@ import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks' import { useFlags } from '../../lib/hooks'
import { startDryRun, startMove } from '../../lib/rclone/api' import { startDryRun, startMove } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { useSchedulingAvailable } from '../../lib/scheduler'
import { usePersistedStore } from '../../store/persisted' import { usePersistedStore } from '../../store/persisted'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { MultiPathFinder } from '../components/PathFinder' import { MultiPathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection' import RemoteOptionsSection from '../components/RemoteOptionsSection'
import AdvancedScheduleSection, { import CronSection from '../components/operation/CronSection'
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter' import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, { import OptionsAccordion, {
type OptionsAccordionItemDef, 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). • 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. • 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 filterGroup = optionGroups.filter
const configGroup = optionGroups.config const configGroup = optionGroups.config
const advanced = useAdvancedSchedule() const [cronExpression, setCronExpression] = useState<string | null>(null)
const schedulingAvailable = useSchedulingAvailable()
const selectedRemotes = useMemo( const selectedRemotes = useMemo(
() => [...(sources || []), dest].filter(Boolean), () => [...(sources || []), dest].filter(Boolean),
@@ -117,7 +117,7 @@ export default function Move() {
return startMove(buildArgs()) return startMove(buildArgs())
}, },
onSuccess: () => { onSuccess: () => {
if (advanced.cronExpression) { if (cronExpression) {
scheduleTaskMutation.mutate() scheduleTaskMutation.mutate()
} }
}, },
@@ -129,9 +129,7 @@ export default function Move() {
const scheduleTaskMutation = useScheduleTask({ const scheduleTaskMutation = useScheduleTask({
operation: 'move', operation: 'move',
cronExpression: advanced.cronExpression, cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => { validate: () => {
if (!sources || sources.length === 0 || !dest) { if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path') 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 (!dest) return 'Please select a destination path'
if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same' if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' 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' return 'START MOVE'
}, [startMoveMutation.isPending, sources, dest, jsonError, advanced.cronExpression]) }, [startMoveMutation.isPending, sources, dest, jsonError, cronExpression])
const buttonIcon = useMemo(() => { const buttonIcon = useMemo(() => {
if (startMoveMutation.isPending) return 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', key: 'config',
category: 'config', category: 'config',
@@ -262,6 +274,8 @@ export default function Move() {
configFlags, configFlags,
copyFlags, copyFlags,
selectedRemotes, selectedRemotes,
cronExpression,
schedulingAvailable,
] ]
) )
@@ -286,21 +300,21 @@ export default function Move() {
const handleResetOptions = useCallback(() => { const handleResetOptions = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
advanced.reset() setCronExpression(null)
startMoveMutation.reset() startMoveMutation.reset()
}) })
}, [advanced.reset, resetJson, startMoveMutation.reset]) }, [resetJson, startMoveMutation.reset])
const handleResetAll = useCallback(() => { const handleResetAll = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
resetLocks() resetLocks()
advanced.reset() setCronExpression(null)
setSources(undefined) setSources(undefined)
setDest(undefined) setDest(undefined)
startMoveMutation.reset() startMoveMutation.reset()
}) })
}, [advanced.reset, resetJson, resetLocks, startMoveMutation.reset]) }, [resetJson, resetLocks, startMoveMutation.reset])
return ( return (
<div className="flex flex-col h-screen gap-10"> <div className="flex flex-col h-screen gap-10">
@@ -314,8 +328,6 @@ export default function Move() {
setDestPath={setDest} setDestPath={setDest}
/> />
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion banner={true} items={accordionItems} /> <OptionsAccordion banner={true} items={accordionItems} />
</OperationWindowContent> </OperationWindowContent>
+28 -18
View File
@@ -7,13 +7,12 @@ import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks' import { useFlags } from '../../lib/hooks'
import { startPurge } from '../../lib/rclone/api' import { startPurge } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { useSchedulingAvailable } from '../../lib/scheduler'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { PathField } from '../components/PathFinder' import { PathField } from '../components/PathFinder'
import AdvancedScheduleSection, { import CronSection from '../components/operation/CronSection'
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter' import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, { import OptionsAccordion, {
type OptionsAccordionItemDef, 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. • 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) 3. USE TEMPLATES (Optional)
Tap the folder icon in the bottom bar to load or save option presets. 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 searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
) )
const advanced = useAdvancedSchedule() const [cronExpression, setCronExpression] = useState<string | null>(null)
const schedulingAvailable = useSchedulingAvailable()
const { const {
jsonError, jsonError,
@@ -90,7 +90,7 @@ export default function Purge() {
return startPurge(buildArgs()) return startPurge(buildArgs())
}, },
onSuccess: async () => { onSuccess: async () => {
if (advanced.cronExpression) { if (cronExpression) {
scheduleTaskMutation.mutate() scheduleTaskMutation.mutate()
} }
}, },
@@ -101,9 +101,7 @@ export default function Purge() {
const scheduleTaskMutation = useScheduleTask({ const scheduleTaskMutation = useScheduleTask({
operation: 'purge', operation: 'purge',
cronExpression: advanced.cronExpression, cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => { validate: () => {
if (!source) { if (!source) {
throw new Error('Please select a source path to purge') throw new Error('Please select a source path to purge')
@@ -116,9 +114,9 @@ export default function Purge() {
if (startPurgeMutation.isPending) return 'STARTING...' if (startPurgeMutation.isPending) return 'STARTING...'
if (!source) return 'Please select a source path' if (!source) return 'Please select a source path'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' 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' return 'START PURGE'
}, [startPurgeMutation.isPending, source, jsonError, advanced.cronExpression]) }, [startPurgeMutation.isPending, source, jsonError, cronExpression])
const buttonIcon = useMemo(() => { const buttonIcon = useMemo(() => {
if (startPurgeMutation.isPending) return 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]) const handleStart = useCallback(() => startPurgeMutation.mutate(), [startPurgeMutation.mutate])
@@ -166,20 +178,20 @@ export default function Purge() {
const handleResetOptions = useCallback(() => { const handleResetOptions = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
advanced.reset() setCronExpression(null)
startPurgeMutation.reset() startPurgeMutation.reset()
}) })
}, [advanced.reset, resetJson, startPurgeMutation.reset]) }, [resetJson, startPurgeMutation.reset])
const handleResetAll = useCallback(() => { const handleResetAll = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
resetLocks() resetLocks()
advanced.reset() setCronExpression(null)
setSource(undefined) setSource(undefined)
startPurgeMutation.reset() startPurgeMutation.reset()
}) })
}, [advanced.reset, resetJson, resetLocks, startPurgeMutation.reset]) }, [resetJson, resetLocks, startPurgeMutation.reset])
return ( return (
<div className="flex flex-col h-screen gap-10"> <div className="flex flex-col h-screen gap-10">
@@ -196,8 +208,6 @@ export default function Purge() {
showFiles={false} showFiles={false}
/> />
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion <OptionsAccordion
defaultExpandedKeys={DEFAULT_EXPANDED_KEYS} defaultExpandedKeys={DEFAULT_EXPANDED_KEYS}
items={accordionItems} 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 { Button, Chip } from '@heroui/react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' 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 { platform } from '@tauri-apps/plugin-os'
import cronstrue from 'cronstrue' import cronstrue from 'cronstrue'
import { formatDistance } from 'date-fns' import { formatDistance } from 'date-fns'
import { import { AlertCircleIcon, Clock7Icon, PauseIcon, PlayIcon, Trash2Icon, ZapIcon } from 'lucide-react'
AlertCircleIcon, import { useCallback, useMemo, useState } from 'react'
Clock7Icon, import { onErrorDialog } from '../../lib/errors'
PauseIcon,
PlayIcon,
StethoscopeIcon,
Trash2Icon,
ZapIcon,
} from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { formatErrorMessage, onErrorDialog } from '../../lib/errors'
import { buildReadablePath } from '../../lib/format' import { buildReadablePath } from '../../lib/format'
import { useNow } from '../../lib/hooks' import { useNow } from '../../lib/hooks'
import { LOCAL_HOST_ID } from '../../lib/hosts' import { LOCAL_HOST_ID } from '../../lib/hosts'
import { import {
type SchedulerTaskStatus, type SchedulerTaskStatus,
schedulerDoctor,
removeScheduledTask as schedulerRemoveTask, removeScheduledTask as schedulerRemoveTask,
schedulerRunNow, schedulerRunNow,
schedulerStatus, schedulerStatus,
updateScheduledTask as schedulerUpdateTask,
schedulerValidateCron, schedulerValidateCron,
setScheduledTaskEnabled, setScheduledTaskEnabled,
useSchedulerSupported, useSchedulerSupported,
@@ -72,29 +62,6 @@ export default function Schedules() {
[onOpen] [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) { if (scheduledTasks.length === 0) {
return ( return (
<div className="flex flex-col items-center justify-center h-screen gap-8"> <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' }} 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) => ( {scheduledTasks.map((task) => (
<TaskCard <TaskCard
key={task.id} key={task.id}
@@ -163,14 +116,6 @@ function TaskCard({
onOpenDrawer: (task: ScheduledTask) => void onOpenDrawer: (task: ScheduledTask) => void
}) { }) {
const queryClient = useQueryClient() 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 // 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). // 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 }), 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 const errorLine = task.registrationError
? `Not scheduled: ${task.registrationError}` ? `Not scheduled: ${task.registrationError}`
: status?.warning : status?.warning
@@ -327,50 +262,9 @@ function TaskCard({
</Tooltip> </Tooltip>
)} )}
<div className="flex flex-col gap-0"> <div className="flex flex-col gap-0">
<Tooltip <p className="w-64 text-sm font-bold truncate text-start">
content="Tap to edit the name" {task.name || 'Untitled Schedule'}
placement="bottom" </p>
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>
<div className="text-sm text-gray-500 text-start"> <div className="text-sm text-gray-500 text-start">
{buildReadablePath(source, 'short')} {'→'}{' '} {buildReadablePath(source, 'short')} {'→'}{' '}
{'destination' in task.args {'destination' in task.args
+56 -27
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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { message, open } from '@tauri-apps/plugin-dialog' import { message, open } from '@tauri-apps/plugin-dialog'
import { import {
CheckIcon,
DownloadIcon, DownloadIcon,
FolderOpenIcon, FolderOpenIcon,
HardDriveIcon, HardDriveIcon,
@@ -157,25 +156,53 @@ export default function BinarySection() {
return ( return (
<BaseSection header={{ title: 'Binary' }}> <BaseSection header={{ title: 'Binary' }}>
<div className="flex flex-col w-full gap-6 px-8 pb-10">
{/* ---- Custom binary ---- */} {/* ---- 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 <CustomBinaryRow
active={active} active={active}
systemPath={systemQuery.data ?? null} systemPath={systemQuery.data ?? null}
rclonePath={rclonePath} rclonePath={rclonePath}
onActivated={invalidateActive} onActivated={invalidateActive}
/> />
</div>
</div>
{/* ---- PATH integration ---- */} {/* ---- 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 <PathIntegrationRow
rclonePath={rclonePath} rclonePath={rclonePath}
isSystemActive={active?.kind === 'system'} isSystemActive={active?.kind === 'system'}
/> />
</div>
</div>
{/* ---- Auto update ---- */} {/* ---- 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 /> <AutoUpdateRow />
</div>
</div>
{/* ---- Versions ---- */} {/* ---- 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"> <div className="flex flex-col overflow-hidden border divide-y rounded-large border-divider divide-divider">
{/* System */} {/* System */}
{systemQuery.data && ( {systemQuery.data && (
@@ -201,7 +228,8 @@ export default function BinarySection() {
{/* Downloaded (managed) */} {/* Downloaded (managed) */}
{downloadedVersions.map((v) => { {downloadedVersions.map((v) => {
const isActive = active?.kind === 'managed' && active.version === v.version const isActive =
active?.kind === 'managed' && active.version === v.version
return ( return (
<VersionRow <VersionRow
key={v.path} key={v.path}
@@ -228,11 +256,14 @@ export default function BinarySection() {
? Math.min(100, Math.round((prog.downloaded / prog.total) * 100)) ? Math.min(100, Math.round((prog.downloaded / prog.total) * 100))
: undefined : undefined
const isDownloading = const isDownloading =
downloadMutation.isPending && downloadMutation.variables === r.version downloadMutation.isPending &&
downloadMutation.variables === r.version
return ( return (
<div key={r.version} className="flex items-center gap-3 px-4 py-3"> <div key={r.version} className="flex items-center gap-3 px-4 py-3">
<div className="flex flex-col flex-1 min-w-0"> <div className="flex flex-col flex-1 min-w-0">
<span className="text-sm text-neutral-500">v{r.version}</span> <span className="text-sm text-neutral-500">
v{r.version}
</span>
{isDownloading && ( {isDownloading && (
<Progress <Progress
aria-label="download progress" aria-label="download progress"
@@ -284,7 +315,7 @@ export default function BinarySection() {
</div> </div>
{updateAvailable && ( {updateAvailable && (
<div className="flex items-center gap-2 -mt-3"> <div className="flex items-center gap-2">
<Chip size="sm" color="primary" variant="flat"> <Chip size="sm" color="primary" variant="flat">
Update available: v{latestVersion} Update available: v{latestVersion}
</Chip> </Chip>
@@ -307,6 +338,7 @@ export default function BinarySection() {
</div> </div>
)} )}
</div> </div>
</div>
</BaseSection> </BaseSection>
) )
} }
@@ -341,13 +373,8 @@ function VersionRow({
{warning && <span className="text-xs text-warning">{warning}</span>} {warning && <span className="text-xs text-warning">{warning}</span>}
</div> </div>
{isActive ? ( {isActive ? (
<Chip <Chip size="sm" color="success" variant="flat">
size="sm" ACTIVE
color="success"
variant="flat"
startContent={<CheckIcon className="w-3 h-3" />}
>
Active
</Chip> </Chip>
) : ( ) : (
<Button <Button
@@ -431,39 +458,38 @@ function CustomBinaryRow({
}) })
if (typeof selected === 'string') { if (typeof selected === 'string') {
setValue(selected) setValue(selected)
// Picking a binary implies using it — activate immediately, no separate button.
useMutationState.mutate(selected)
} }
} }
return ( return (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex gap-2">
<Input <Input
value={value} value={value}
onValueChange={setValue} onValueChange={setValue}
size="sm" onKeyDown={(e) => {
if (e.key === 'Enter' && value) {
useMutationState.mutate(value)
}
}}
size="lg"
placeholder="/path/to/rclone" placeholder="/path/to/rclone"
autoComplete="off" autoComplete="off"
endContent={ endContent={
useMutationState.isPending ? (
<Spinner size="sm" />
) : (
<button <button
type="button" type="button"
onClick={browse} onClick={browse}
className="transition-colors text-neutral-400 hover:text-neutral-200" className="transition-colors text-neutral-400 hover:text-neutral-200"
> >
<FolderOpenIcon className="w-4 h-4" /> <FolderOpenIcon className="w-5 h-5" />
</button> </button>
)
} }
/> />
<Button
size="sm"
variant="flat"
isDisabled={!value}
isLoading={useMutationState.isPending}
onPress={() => useMutationState.mutate(value)}
data-focus-visible="false"
>
Use
</Button>
</div>
{isCustomActive && ( {isCustomActive && (
<span className="text-xs text-success"> <span className="text-xs text-success">
Currently using a custom binary Currently using a custom binary
@@ -538,6 +564,9 @@ function PathIntegrationRow({
> >
Add rclone to PATH Add rclone to PATH
</Checkbox> </Checkbox>
<span className="text-xs text-neutral-500">
Lets you call rclone from your terminal.
</span>
{isSystemActive && ( {isSystemActive && (
<span className="text-xs text-neutral-500"> <span className="text-xs text-neutral-500">
The system rclone is already on your PATH. 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 { usePersistedStore } from '../../../store/persisted'
import BaseSection from './BaseSection' 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() { export default function GeneralSection() {
const settingsPass = usePersistedStore((state) => state.settingsPass) const settingsPass = usePersistedStore((state) => state.settingsPass)
const setSettingsPass = usePersistedStore((state) => state.setSettingsPass) const setSettingsPass = usePersistedStore((state) => state.setSettingsPass)
@@ -317,6 +372,8 @@ export default function GeneralSection() {
</div> </div>
</div> </div>
<ToolbarShortcutRow />
{!isFlathub && ( {!isFlathub && (
<div className="flex flex-row justify-center w-full gap-8 px-8"> <div className="flex flex-row justify-center w-full gap-8 px-8">
<div className="flex flex-col items-end flex-grow gap-2"> <div className="flex flex-col items-end flex-grow gap-2">
@@ -336,3 +393,145 @@ export default function GeneralSection() {
</BaseSection> </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 { ask, message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import { import {
MessageCircleIcon,
PencilIcon, PencilIcon,
PlusIcon,
SendIcon, SendIcon,
SettingsIcon, SettingsIcon,
Trash2Icon, Trash2Icon,
TriangleAlertIcon, TriangleAlertIcon,
} from 'lucide-react' } from 'lucide-react'
import { useMemo, useState } from 'react' import { type ReactNode, useMemo, useState } from 'react'
import { import {
FREE_MAX_TARGETS, FREE_MAX_TARGETS,
NOTIFICATION_PROVIDERS, NOTIFICATION_PROVIDERS,
@@ -97,6 +97,28 @@ export default function NotificationsSection() {
onPress={() => handleAddPress(provider)} 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> </div>
</section> </section>
@@ -153,7 +175,6 @@ function ProviderCard({
data-focus-visible="false" data-focus-visible="false"
> >
<CardBody className="relative flex flex-row items-center gap-3 px-4"> <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 <ProviderIcon
provider={provider} provider={provider}
className={cn('size-8 shrink-0', providerMeta.accentClass)} 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({ function NotificationTargetCard({
target, target,
catalog, catalog,
+2 -5
View File
@@ -52,10 +52,7 @@ export default function RemotesSection() {
const remotes = useMemo(() => remotesQuery.data ?? [], [remotesQuery.data]) const remotes = useMemo(() => remotesQuery.data ?? [], [remotesQuery.data])
const sortedRemotes = useMemo( const sortedRemotes = useMemo(() => [...remotes].sort((a, b) => a.localeCompare(b)), [remotes])
() => [...remotes].sort((a, b) => a.localeCompare(b)),
[remotes]
)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
@@ -348,7 +345,7 @@ function RemoteCard({
<div className="flex items-center justify-between h-full"> <div className="flex items-center justify-between h-full">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<img src={imageUrl} className="object-contain ml-2 size-10" alt={remote} /> <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>
<div className="flex items-center justify-end gap-4"> <div className="flex items-center justify-end gap-4">
{/* Storage info boxes */} {/* 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, EyeIcon,
GlobeIcon, GlobeIcon,
InfoIcon, InfoIcon,
KeyboardIcon,
MedalIcon, MedalIcon,
PackageIcon, PackageIcon,
SatelliteDishIcon, SatelliteDishIcon,
@@ -34,7 +33,6 @@ import MobileSection from './MobileSection'
import NotificationsSection from './NotificationsSection' import NotificationsSection from './NotificationsSection'
import ProxySection from './ProxySection' import ProxySection from './ProxySection'
import RemotesSection from './RemotesSection' import RemotesSection from './RemotesSection'
import ToolbarSection from './ToolbarSection'
export default function Settings() { export default function Settings() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
@@ -143,9 +141,11 @@ export default function Settings() {
variant="light" variant="light"
destroyInactiveTabPanel={false} destroyInactiveTabPanel={false}
disableAnimation={true} 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={{ 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', tab: 'h-14 justify-start rounded-large',
tabContent: 'pl-8', tabContent: 'pl-8',
}} }}
@@ -167,19 +167,6 @@ export default function Settings() {
> >
<GeneralSection /> <GeneralSection />
</Tab> </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 <Tab
key="remotes" key="remotes"
title={ title={
@@ -193,6 +180,46 @@ export default function Settings() {
> >
<RemotesSection /> <RemotesSection />
</Tab> </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 <Tab
key="hosts" key="hosts"
title={ title={
@@ -289,46 +316,6 @@ export default function Settings() {
> >
<ProxySection /> <ProxySection />
</Tab> </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 <Tab
key="license" key="license"
title={ title={
+29 -17
View File
@@ -7,14 +7,13 @@ import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks' import { useFlags } from '../../lib/hooks'
import { startDryRun, startSync } from '../../lib/rclone/api' import { startDryRun, startSync } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { useSchedulingAvailable } from '../../lib/scheduler'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { PathFinder } from '../components/PathFinder' import { PathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection' import RemoteOptionsSection from '../components/RemoteOptionsSection'
import AdvancedScheduleSection, { import CronSection from '../components/operation/CronSection'
useAdvancedSchedule,
} from '../components/operation/AdvancedScheduleSection'
import OperationFooter from '../components/operation/OperationFooter' import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, { import OptionsAccordion, {
type OptionsAccordionItemDef, 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). 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. 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 filterGroup = optionGroups.filter
const configGroup = optionGroups.config 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]) const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest])
@@ -139,7 +139,7 @@ export default function Sync() {
return startSync(buildArgs()) return startSync(buildArgs())
}, },
onSuccess: () => { onSuccess: () => {
if (advanced.cronExpression) { if (cronExpression) {
scheduleTaskMutation.mutate() scheduleTaskMutation.mutate()
} }
}, },
@@ -148,9 +148,7 @@ export default function Sync() {
const scheduleTaskMutation = useScheduleTask({ const scheduleTaskMutation = useScheduleTask({
operation: 'sync', operation: 'sync',
cronExpression: advanced.cronExpression, cronExpression,
configId: advanced.configId,
binaryPath: advanced.binaryPath,
validate: () => { validate: () => {
if (!source || !dest) { if (!source || !dest) {
throw new Error('Please select both a source and destination path') 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 (!dest) return 'Please select a destination path'
if (source === dest) return 'Source and destination cannot be the same' if (source === dest) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options' 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' return 'START SYNC'
}, [startSyncMutation.isPending, source, dest, jsonError, advanced.cronExpression]) }, [startSyncMutation.isPending, source, dest, jsonError, cronExpression])
const buttonIcon = useMemo(() => { const buttonIcon = useMemo(() => {
if (startSyncMutation.isPending) return 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', key: 'config',
category: 'config', category: 'config',
@@ -276,6 +288,8 @@ export default function Sync() {
configFlags, configFlags,
selectedRemotes, selectedRemotes,
remotesGroup, remotesGroup,
cronExpression,
schedulingAvailable,
] ]
) )
@@ -300,21 +314,21 @@ export default function Sync() {
const handleResetOptions = useCallback(() => { const handleResetOptions = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
advanced.reset() setCronExpression(null)
startSyncMutation.reset() startSyncMutation.reset()
}) })
}, [advanced.reset, resetJson, startSyncMutation.reset]) }, [resetJson, startSyncMutation.reset])
const handleResetAll = useCallback(() => { const handleResetAll = useCallback(() => {
startTransition(() => { startTransition(() => {
resetJson() resetJson()
resetLocks() resetLocks()
advanced.reset() setCronExpression(null)
setDest(undefined) setDest(undefined)
setSource(undefined) setSource(undefined)
startSyncMutation.reset() startSyncMutation.reset()
}) })
}, [advanced.reset, resetJson, resetLocks, startSyncMutation.reset]) }, [resetJson, resetLocks, startSyncMutation.reset])
return ( return (
<div className="flex flex-col h-screen gap-10"> <div className="flex flex-col h-screen gap-10">
@@ -330,8 +344,6 @@ export default function Sync() {
destOptions={DEST_OPTIONS} destOptions={DEST_OPTIONS}
/> />
<AdvancedScheduleSection advanced={advanced} />
<OptionsAccordion banner={true} items={accordionItems} /> <OptionsAccordion banner={true} items={accordionItems} />
</OperationWindowContent> </OperationWindowContent>
+12 -4
View File
@@ -116,15 +116,23 @@ export default function Toolbar() {
// Eager per-remote capability probes (operations/fsinfo), cached hard. Feeds the synchronous // 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 // 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. // 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(() => { // 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> = {} const map: Record<string, RcloneFeatures> = {}
remotes.forEach((remote, i) => { remotes.forEach((remote, i) => {
const features = fsInfoQueries[i]?.data?.Features const features = results[i]?.data?.Features
if (features) map[remote] = features if (features) map[remote] = features
}) })
return map return map
}, [remotes, fsInfoQueries]) },
})
const [searchString, setSearchString] = useState('') const [searchString, setSearchString] = useState('')
const [searchStringDebounced] = useDebounce(searchString, 40) const [searchStringDebounced] = useDebounce(searchString, 40)