interface adjustments
This commit is contained in:
+33
-10
@@ -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
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"minimumVersion": "3.1.0",
|
"minimumVersion": "3.1.0",
|
||||||
"okVersion": "3.1.0"
|
"okVersion": "3.7.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-1
@@ -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",
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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} />
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 "Record shortcut" 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 "Record shortcut" 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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user