zookeeper + cleanup

This commit is contained in:
FTCHD
2026-07-10 14:41:22 +03:00
parent 44e71643cd
commit 02bfdb8ddf
72 changed files with 6111 additions and 5335 deletions
+1 -1
View File
@@ -55,7 +55,7 @@
"formatter": {
"quoteStyle": "single",
"quoteProperties": "preserve",
"trailingComma": "es5",
"trailingCommas": "es5",
"semicolons": "asNeeded"
},
"globals": ["it", "describe", "expect", "test"]
+46
View File
@@ -0,0 +1,46 @@
import * as Sentry from '@sentry/browser'
import { message } from '@tauri-apps/plugin-dialog'
// Coerce an unknown thrown value into a user-facing string. Mirrors the
// `error instanceof Error ? error.message : <fallback>` idiom hand-written across the app.
// Pass `String(error)` as the fallback to preserve sites that surfaced the raw value.
export function formatErrorMessage(error: unknown, fallback = 'An unknown error occurred'): string {
return error instanceof Error ? error.message : fallback
}
interface ReportErrorOptions {
title: string
fallback?: string
okLabel?: string
// Defaults to capturing. Pass `false` for sites that did not call Sentry.captureException.
capture?: boolean
// When provided, forwarded to console.error before the dialog, with the error appended
// (so `['[switchConfig] failed']` -> console.error('[switchConfig] failed', error)). Omit to
// suppress console.error entirely for sites that never logged.
log?: unknown[]
}
// console.error (optional) + Sentry.captureException (unless capture === false) + error dialog.
export async function reportError(error: unknown, options: ReportErrorOptions): Promise<void> {
const { title, fallback, okLabel, capture, log } = options
if (log) {
console.error(...log, error)
}
if (capture !== false) {
Sentry.captureException(error)
}
await message(formatErrorMessage(error, fallback), {
title,
kind: 'error',
...(okLabel ? { okLabel } : {}),
})
}
// A ready-made TanStack Query `onError` handler that reports through reportError.
export function onErrorDialog(
title: string,
fallback?: string,
options?: Omit<ReportErrorOptions, 'title' | 'fallback'>
): (error: unknown) => Promise<void> {
return (error: unknown) => reportError(error, { title, fallback, ...options })
}
+33
View File
@@ -0,0 +1,33 @@
import { getCurrentWindow } from '@tauri-apps/api/window'
import type { ConfigFile } from '../types/config'
// Wire names for the cross-window app-lifecycle events. These strings cross the Tauri event bus
// and are listened for in main.ts (loaded only by the hidden 'main' window) — keep them stable.
export const CLOSE_APP = 'close-app'
export const RELAUNCH_APP = 'relaunch-app'
export const RESTART_RCLONE = 'restart-rclone'
// Full lifecycle snapshot carried on a restart request. The main window may not have rehydrated
// the initiating webview's store writes yet, so the intended values ride along in the payload.
export interface RestartRclonePayload {
rclonePath?: string
defaultConfigPath?: string
configFiles?: ConfigFile[]
activeConfigId?: string | null
proxy?: { url: string; ignoredHosts: string[] } | undefined
}
export type AppEventPayload = {
[CLOSE_APP]: undefined
[RELAUNCH_APP]: undefined
[RESTART_RCLONE]: RestartRclonePayload
}
// Emit an app-lifecycle event. Tauri's window.emit broadcasts globally, so the single main-window
// listener receives it regardless of which webview calls this.
export async function emitToMain<E extends keyof AppEventPayload>(
event: E,
payload?: AppEventPayload[E]
): Promise<void> {
await getCurrentWindow().emit(event, payload)
}
+34
View File
@@ -1,8 +1,42 @@
import { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { sortByName } from './flags'
import rclone from './rclone/client'
import { SERVE_TYPES } from './rclone/constants'
// Wall-clock tick for values derived from "now" (relative timestamps, next cron occurrences).
// Memoizing such values without a time dep freezes them at their last dep change. Pass null to
// pause (e.g. while a drawer is closed); re-arming refreshes immediately.
export function useNow(intervalMs: number | null = 30_000): number {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (intervalMs === null) {
return
}
setNow(Date.now())
const id = setInterval(() => setNow(Date.now()), intervalMs)
return () => clearInterval(id)
}, [intervalMs])
return now
}
// Shared query options for a remote's `/config/get`. No default staleTime: most consumers rely on
// staleTime-0 refetch-on-mount for cross-window freshness (each webview has its own QueryClient);
// the handful that want caching spread `staleTime` per-site.
export function remoteConfigQueryOptions(remote: string | undefined | null) {
return {
queryKey: ['remote', remote, 'config'] as const,
queryFn: () => rclone('/config/get', { params: { query: { name: remote! } } }),
enabled: !!remote && remote !== 'UI_LOCAL_FS' && remote !== 'UI_FAVORITES',
}
}
export function useRemoteConfig(remote: string | undefined | null) {
return useQuery(remoteConfigQueryOptions(remote))
}
export function useFlags() {
const allFlagsQuery = useQuery({
queryKey: ['options', 'all'],
+20 -2
View File
@@ -1,4 +1,5 @@
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { platform } from '@tauri-apps/plugin-os'
import pRetry from 'p-retry'
import createRCDClient from 'rclone-sdk'
@@ -14,6 +15,25 @@ export interface Host {
export const LOCAL_HOST_ID = 'local' as const
// The rclone RC daemon port. Keep in sync with the hardcoded port in the Rust
// start_cloudflared_tunnel command.
export const RC_PORT = 5572
export const RC_LOCAL_URL = `http://localhost:${RC_PORT}`
/** The canonical local-machine host, used as the fallback whenever no reachable host is selected. */
export function makeLocalHost(): Host {
const os = platform()
return {
id: LOCAL_HOST_ID,
name: 'Local Machine',
url: RC_LOCAL_URL,
// platform() is wider than Host['os'] (ios/android/freebsd/...); desktop builds only see
// these three — anything else falls back to linux, mirroring getHostInfo's normalization.
os: os === 'windows' || os === 'macos' ? os : 'linux',
cliVersion: 'unknown',
}
}
export const LABEL_FOR_OS = {
windows: 'Windows',
macos: 'macOS',
@@ -43,8 +63,6 @@ export async function getHostInfo({
authHeader = `Basic ${btoa(`${authUser}:${authPassword}`)}`
}
console.log('[getHostInfo] authHeader', authHeader)
const rcloneClient = createRCDClient({
baseUrl: url,
headers: authHeader
+57 -45
View File
@@ -3,44 +3,78 @@ import { fetch } from '@tauri-apps/plugin-http'
import { platform } from '@tauri-apps/plugin-os'
import { usePersistedStore } from '../store/persisted'
export async function validateLicense(licenseKey: string) {
console.log('[validateLicense]')
interface LicenseCallLogs {
start: string
uidFail: string
uidMissing: string
fetchFail: string
errorResponse: string
}
// Shared scaffold for the license API calls: builds the machine id, POSTs to rcloneui.com, and
// runs the error triage. Per-branch log strings are passed in so each caller's logs stay identical.
async function licenseCall<T extends { error?: string }>(
endpoint: string,
licenseKey: string,
extraBody: Record<string, unknown>,
failVerb: string,
logs: LicenseCallLogs
): Promise<T> {
console.log(logs.start)
let id
try {
id = await invoke('get_uid')
} catch (e) {
console.error('[validateLicense] failed to build unique identifier')
console.error(logs.uidFail)
console.error(JSON.stringify(e))
throw new Error('Failed to build unique identifier. Please try again later.')
}
if (!id) {
console.error('[validateLicense] missing unique identifier')
console.error(logs.uidMissing)
throw new Error('Failed to build unique identifier. Please try again later.')
}
const validationResponse = await fetch('https://rcloneui.com/api/v2/validate', {
const response = await fetch(`https://rcloneui.com${endpoint}`, {
method: 'POST',
body: JSON.stringify({
licenseKey,
id,
platform: platform(),
...extraBody,
}),
})
.then((r) => r.json() as Promise<{ error: string; valid: boolean }>)
.then((r) => r.json() as Promise<T>)
.catch((e) => {
console.error('[validateLicense] failed to validate license')
console.error(logs.fetchFail)
console.error(JSON.stringify(e))
throw new Error('Failed to validate license. Are you connected to the internet?')
throw new Error(`Failed to ${failVerb} license. Are you connected to the internet?`)
})
if (validationResponse.error) {
console.error('[validateLicense] failed to validate license')
throw new Error(validationResponse.error)
if (response.error) {
console.error(logs.errorResponse)
throw new Error(response.error)
}
return response
}
export async function validateLicense(licenseKey: string) {
const validationResponse = await licenseCall<{ error: string; valid: boolean }>(
'/api/v2/validate',
licenseKey,
{ platform: platform() },
'validate',
{
start: '[validateLicense]',
uidFail: '[validateLicense] failed to build unique identifier',
uidMissing: '[validateLicense] missing unique identifier',
fetchFail: '[validateLicense] failed to validate license',
errorResponse: '[validateLicense] failed to validate license',
}
)
if (!validationResponse.valid) {
console.error('[validateLicense] invalid license key')
throw new Error('Invalid license key. Please check your license key and try again.')
@@ -52,41 +86,19 @@ export async function validateLicense(licenseKey: string) {
}
export async function revokeMachineLicense(licenseKey: string) {
console.log('[revokeMachineLicense]')
let id
try {
id = await invoke('get_uid')
} catch (e) {
console.error('[revokeMachineLicense] failed to build unique identifier')
console.error(JSON.stringify(e))
throw new Error('Failed to build unique identifier. Please try again later.')
}
if (!id) {
console.error('[revokeMachineLicense] missing unique identifier')
throw new Error('Failed to build unique identifier. Please try again later.')
}
const revocationResponse = await fetch('https://rcloneui.com/api/v1/revoke', {
method: 'POST',
body: JSON.stringify({
const revocationResponse = await licenseCall<{ error: string; revoked: boolean }>(
'/api/v1/revoke',
licenseKey,
id,
}),
})
.then((r) => r.json() as Promise<{ error: string; revoked: boolean }>)
.catch((e) => {
console.error('[revokeMachineLicense] failed to revoke license, fetch failed')
console.error(JSON.stringify(e))
throw new Error('Failed to revoke license. Are you connected to the internet?')
})
if (revocationResponse.error) {
console.error('[revokeMachineLicense] failed to revoke license, has error response')
throw new Error(revocationResponse.error)
{},
'revoke',
{
start: '[revokeMachineLicense]',
uidFail: '[revokeMachineLicense] failed to build unique identifier',
uidMissing: '[revokeMachineLicense] missing unique identifier',
fetchFail: '[revokeMachineLicense] failed to revoke license, fetch failed',
errorResponse: '[revokeMachineLicense] failed to revoke license, has error response',
}
)
if (!revocationResponse.revoked) {
console.error('[revokeMachineLicense] failed to revoke license, missing revoked response')
-1
View File
@@ -1,4 +1,3 @@
'use no memo'
import { persistQueryClient } from '@tanstack/query-persist-client-core'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
import { QueryClient } from '@tanstack/react-query'
+7 -15
View File
@@ -2,7 +2,7 @@ import * as Sentry from '@sentry/browser'
import { message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import pRetry from 'p-retry'
import { useHostStore } from '../../store/host'
import { selectActiveConfigFile, useHostStore } from '../../store/host'
import { useStore } from '../../store/memory'
import type { JobItem } from '../../types/jobs'
import type { FlagValue } from '../../types/rclone'
@@ -108,7 +108,9 @@ function serializeOptions(
}
async function hasStat(path: string) {
try {
// No try/catch: a transport failure must propagate as the real error instead of being
// masked as "Source does not exist". A genuinely missing path returns a response with no
// item, which still yields false.
const { root, filePath } = getFsInfo(path)
const r = await rclone('/operations/stat', {
params: {
@@ -118,13 +120,7 @@ async function hasStat(path: string) {
},
},
})
if (!r || !r.item) {
return false
}
return true
} catch {
return false
}
return !!r?.item
}
export async function startCopy({
@@ -1280,7 +1276,7 @@ export async function removeConfigPassword() {
console.log('[removeConfigPassword]')
const state = useHostStore.getState()
const activeConfig = state.activeConfigFile
const activeConfig = selectActiveConfigFile(state)
if (!activeConfig || !activeConfig.id) {
throw new Error('No active configuration selected.')
@@ -1317,16 +1313,12 @@ export async function setConfigPassword(options: {
console.log('[setConfigPassword]')
const state = useHostStore.getState()
const activeConfig = state.activeConfigFile
const activeConfig = selectActiveConfigFile(state)
if (!activeConfig || !activeConfig.id) {
throw new Error('No active configuration selected.')
}
// if (!activeConfig.isEncrypted) {
// throw new Error('Configuration is not encrypted.')
// }
const password = options.password
if (!password) {
+138 -132
View File
@@ -1,21 +1,27 @@
import * as Sentry from '@sentry/browser'
import { invoke } from '@tauri-apps/api/core'
import { sep } from '@tauri-apps/api/path'
import { getAllWindows } from '@tauri-apps/api/window'
import { message } from '@tauri-apps/plugin-dialog'
import { Command } from '@tauri-apps/plugin-shell'
import { useHostStore } from '../../store/host'
import { selectActiveConfigFile, useHostStore } from '../../store/host'
import { usePersistedStore } from '../../store/persisted'
import type { ConfigFile } from '../../types/config'
import { RESTART_RCLONE, emitToMain } from '../events'
import { getConfigParentFolder } from '../format'
import { getConfigPath, isInternalRcloneInstalled, isSystemRcloneInstalled } from './common'
import { getConfigPath } from './common'
interface ExecResult {
code: number | null
stdout: string
stderr: string
}
export interface RcloneCliCommandContext {
command: Command<string>
rclonePath: string
args: string[]
activeConfig: ConfigFile
configPath: string
configDirectory: string
env: Record<string, string>
flavour: 'system' | 'internal'
}
export async function promptForConfigPassword(message: string) {
@@ -42,19 +48,35 @@ export async function promptForConfigPassword(message: string) {
}
}
/** Returns the active rclone binary path (set during startup adoption). */
export function getActiveRclonePath(): string {
const path = usePersistedStore.getState().rclonePath
if (!path) {
throw new Error('No rclone binary is configured.')
}
return path
}
async function validateConfigAccess(
commandName: 'rclone-system' | 'rclone-internal',
env: Record<string, string>
rclonePath: string,
env: Record<string, string>,
timeoutMs: number | null = 15000
): Promise<{
success: boolean
timedOut?: boolean
code?: number | null
stderr?: string
error?: Error
}> {
console.log('[validateConfigAccess] command:', commandName)
console.log('[validateConfigAccess] rclone:', rclonePath)
try {
const command = Command.create(commandName, ['config', 'dump'], { env })
const result = await command.execute()
const result = await invoke<ExecResult>('exec_rclone', {
path: rclonePath,
args: ['config', 'dump'],
env,
stdinLines: null,
timeoutMs,
})
console.log('[validateConfigAccess] exit code:', result.code)
if (result.code === 0) {
@@ -63,6 +85,9 @@ async function validateConfigAccess(
return {
success: false,
// A null code means the probe was killed at the deadline — rclone never ruled on the
// credentials, so callers must not treat this as a wrong password.
timedOut: result.code === null,
code: result.code ?? null,
stderr: result.stderr,
}
@@ -79,7 +104,7 @@ export async function ensureEncryptedConfigEnv(
activeConfig: ConfigFile,
env: Record<string, string>,
autoPromptForPassword: boolean,
commandName: 'rclone-system' | 'rclone-internal',
rclonePath: string,
promptMessage: string
) {
console.log('[ensureEncryptedConfigEnv] ensuring encrypted config env for:', activeConfig.id)
@@ -93,7 +118,9 @@ export async function ensureEncryptedConfigEnv(
RCLONE_CONFIG_PASS_COMMAND: activeConfig.passCommand,
}
const validation = await validateConfigAccess(commandName, validationEnv)
// Password commands can block on user interaction (biometric prompt, pinentry), so this
// probe must not have a deadline.
const validation = await validateConfigAccess(rclonePath, validationEnv, null)
if (validation.success) {
console.log('[ensureEncryptedConfigEnv] passCommand validation succeeded')
env.RCLONE_CONFIG_PASS_COMMAND = activeConfig.passCommand
@@ -146,7 +173,7 @@ export async function ensureEncryptedConfigEnv(
RCLONE_CONFIG_PASS: password,
}
const validation = await validateConfigAccess(commandName, validationEnv)
const validation = await validateConfigAccess(rclonePath, validationEnv)
if (validation.success) {
console.log('[ensureEncryptedConfigEnv] password validation succeeded')
env.RCLONE_CONFIG_PASS = password
@@ -155,6 +182,16 @@ export async function ensureEncryptedConfigEnv(
console.error('[ensureEncryptedConfigEnv] password validation failed', validation.code)
if (validation.timedOut || validation.error) {
// Indeterminate result (probe killed at its deadline, or rclone failed to launch) —
// the password may well be correct, so never clear a stored one or reprompt over it.
throw new Error(
validation.error
? `Could not verify the configuration password: ${validation.error.message}`
: 'Timed out while verifying the configuration password. Please try again.'
)
}
if (passwordSource === 'stored') {
console.log('[ensureEncryptedConfigEnv] clearing invalid stored password')
if (activeConfigId && updateConfigFile) {
@@ -207,21 +244,68 @@ export async function ensureEncryptedConfigEnv(
}
}
/**
* Builds the environment map for running rclone: proxy vars, config location (always set to the
* resolved config path), and encrypted-config credentials. Shared by the daemon and one-off CLI.
*/
export async function buildRcloneEnv(opts: {
activeConfig: ConfigFile
configDirectory: string
configPath: string
proxy?: { url: string; ignoredHosts: string[] } | undefined
rclonePath: string
autoPromptForPassword?: boolean
additionalEnv?: Record<string, string>
}): Promise<Record<string, string>> {
const env: Record<string, string> = {}
if (opts.proxy?.url) {
env.http_proxy = opts.proxy.url
env.https_proxy = opts.proxy.url
env.HTTP_PROXY = opts.proxy.url
env.HTTPS_PROXY = opts.proxy.url
env.no_proxy = opts.proxy.ignoredHosts.join(',')
env.NO_PROXY = opts.proxy.ignoredHosts.join(',')
}
// Always pin the config location. For a system + default-config user this equals rclone's own
// default (explicit = default), and for managed/custom it prevents falling back to a wrong path.
env.RCLONE_CONFIG_DIR = opts.configDirectory
env.RCLONE_CONFIG = opts.configPath.endsWith('rclone.conf')
? opts.configPath
: `${opts.configDirectory}${sep()}rclone.conf`
if (opts.activeConfig.isEncrypted) {
await ensureEncryptedConfigEnv(
opts.activeConfig,
env,
opts.autoPromptForPassword ?? true,
opts.rclonePath,
`Please enter the current password for "${opts.activeConfig.label}"`
)
}
if (opts.additionalEnv) {
Object.assign(env, opts.additionalEnv)
}
return env
}
async function createRcloneCliCommand(
args: string[],
additionalEnv?: Record<string, string>,
autoPromptForPassword = true
): Promise<RcloneCliCommandContext> {
console.log('[createRcloneCliCommand] creating rclone CLI command with args:', args)
const env: Record<string, string> = {}
const hostStore = useHostStore.getState()
const activeConfig = hostStore.activeConfigFile
const activeConfig = selectActiveConfigFile(hostStore)
if (!activeConfig || !activeConfig.id) {
throw new Error('No active configuration selected.')
}
console.log('[createRcloneCliCommand] active config:', activeConfig)
const rclonePath = getActiveRclonePath()
let configPath: string
try {
@@ -231,150 +315,72 @@ async function createRcloneCliCommand(
throw error
}
console.log('[createRcloneCliCommand] config path:', configPath)
const configDirectory = getConfigParentFolder(configPath)
console.log('[createRcloneCliCommand] config directory:', configDirectory)
const proxy = hostStore.proxy
console.log('[createRcloneCliCommand] proxy:', proxy)
if (proxy?.url) {
env.http_proxy = proxy.url
env.https_proxy = proxy.url
env.HTTP_PROXY = proxy.url
env.HTTPS_PROXY = proxy.url
env.no_proxy = proxy.ignoredHosts.join(',')
env.NO_PROXY = proxy.ignoredHosts.join(',')
}
console.log('[createRcloneCliCommand] checking for system rclone installation')
const hasSystem = await isSystemRcloneInstalled()
console.log('[createRcloneCliCommand] checking for internal rclone installation')
const hasInternal = await isInternalRcloneInstalled()
console.log('[createRcloneCliCommand] has system:', hasSystem)
console.log('[createRcloneCliCommand] has internal:', hasInternal)
if (!hasSystem && !hasInternal) {
console.log('[createRcloneCliCommand] no rclone installation found')
const error = new Error('Unable to locate an rclone installation.')
Sentry.captureException(error)
throw error
}
const flavour = hasSystem ? 'system' : 'internal'
const commandName = flavour === 'system' ? 'rclone-system' : 'rclone-internal'
if (!hasSystem || activeConfig.id !== 'default') {
console.log('[createRcloneCliCommand] setting config directory and path')
env.RCLONE_CONFIG_DIR = configDirectory
env.RCLONE_CONFIG = configPath.endsWith('rclone.conf')
? configPath
: `${configDirectory}${sep()}rclone.conf`
}
if (activeConfig.isEncrypted) {
console.log('[createRcloneCliCommand] ensuring encrypted configuration access')
await ensureEncryptedConfigEnv(
const env = await buildRcloneEnv({
activeConfig,
env,
configDirectory,
configPath,
proxy: hostStore.proxy,
rclonePath,
autoPromptForPassword,
commandName,
`Please enter the current password for "${activeConfig.label}"`
)
}
if (additionalEnv) {
console.log('[createRcloneCliCommand] setting additional environment')
Object.assign(env, additionalEnv)
}
console.log(
'[createRcloneCliCommand] creating command, name:',
commandName,
'args:',
args,
'env:',
env
)
const command = Command.create(commandName, args, { env })
console.log('[createRcloneCliCommand] command created')
additionalEnv,
})
return {
command,
rclonePath,
args,
activeConfig,
configPath,
configDirectory,
env,
flavour,
}
}
export async function runRcloneCli(args: string[], input: string[] = []) {
const { command } = await createRcloneCliCommand(args, undefined, true)
let stdout = ''
let stderr = ''
const { rclonePath, env } = await createRcloneCliCommand(args, undefined, true)
console.log('[runRcloneCli] running command', 'args:', args, 'input:', input)
return await new Promise<void>((resolve, reject) => {
command.stdout.on('data', (line) => {
console.log('[runRcloneCli] stdout:', line)
stdout += line
let result: ExecResult
try {
result = await invoke<ExecResult>('exec_rclone', {
path: rclonePath,
args,
env,
stdinLines: input.length > 0 ? input : null,
// Config-writing operations must not be interrupted by a timeout.
timeoutMs: null,
})
command.stderr.on('data', (line) => {
console.log('[runRcloneCli] stderr:', line)
stderr += line
})
command.addListener('error', (event) => {
console.log('[runRcloneCli] error:', event)
const error = typeof event === 'string' ? new Error(event) : event
Sentry.captureException(error)
reject(error instanceof Error ? error : new Error('Unknown rclone CLI error.'))
})
command.addListener('close', (event) => {
console.log('[runRcloneCli] close:', event)
if (event.code === 0) {
resolve()
return
} catch (error) {
const err = error instanceof Error ? error : new Error(String(error))
Sentry.captureException(err)
throw err
}
if (result.code !== 0) {
const error = new Error(
`rclone command failed (code ${event.code ?? 'unknown'}): ${stderr || stdout}`
`rclone command failed (code ${result.code ?? 'unknown'}): ${result.stderr || result.stdout}`
)
Sentry.captureException(error)
reject(error)
})
command
.spawn()
.then(async (child) => {
console.log('[runRcloneCli] child:', child)
for (const line of input) {
console.log('[runRcloneCli] writing input:', line)
await child.write(`${line}\n`)
await new Promise((resolve) => setTimeout(resolve, 100))
console.log('[runRcloneCli] input written')
throw error
}
})
.catch((error) => {
console.log('[runRcloneCli] error:', error)
Sentry.captureException(error)
reject(error)
})
})
}
export async function restartActiveRclone() {
try {
;(await getAllWindows())
.filter((window) => window.label === 'main')[0]
.emit('restart-rclone')
// await getCurrentWindow().emit('restart-rclone')
// The main window's store may not have rehydrated this webview's writes before the restart
// runs — carry a full lifecycle snapshot from THIS webview's fresh stores in the payload.
const host = useHostStore.getState()
const persisted = usePersistedStore.getState()
await emitToMain(RESTART_RCLONE, {
rclonePath: persisted.rclonePath,
defaultConfigPath: host.defaultConfigPath,
configFiles: host.configFiles,
activeConfigId: host.activeConfigId,
proxy: host.proxy,
})
} catch (error) {
Sentry.captureException(error)
console.error('[restartActiveRclone] failed to emit restart event', error)
+41 -69
View File
@@ -10,7 +10,7 @@ import createRCDClient, {
type OpenApiRequiredKeysOf,
type RCDClient,
} from 'rclone-sdk'
import { usePersistedStore } from '../../store/persisted'
import { selectCurrentHost, usePersistedStore } from '../../store/persisted'
const RE_RECONNECT = /rclone config reconnect (\S+?):/
@@ -52,7 +52,7 @@ let client: RCDClient | null = null
function getClient() {
if (!client) {
const currentHost = usePersistedStore.getState().currentHost
const currentHost = selectCurrentHost(usePersistedStore.getState())
if (!currentHost) {
console.error('[rclone] No current host')
throw new Error('No current host')
@@ -86,17 +86,19 @@ type InitParam<Init> = OpenApiRequiredKeysOf<Init> extends never
? [(Init & { [key: string]: unknown })?]
: [Init & { [key: string]: unknown }]
export default async function rclone<
Path extends OpenApiClientPathsWithMethod<RCDClient, 'post'>,
Init extends OpenApiMaybeOptionalInit<Paths[Path], 'post'> = OpenApiMaybeOptionalInit<
Paths[Path],
'post'
>,
>(
path: Path,
...init: InitParam<Init>
): Promise<OpenApiMethodResponse<RCDClient, 'post', Path, Init>> {
console.log('[rclone] REQUEST', path, {
type RequestResult = {
error?: unknown
data?: unknown
response: Response
}
// Shared transport core for the sync (POST) and async (ASYNC) RC calls. The two exported wrappers
// differ only in the client method, the log prefix, and the return cast; everything else — client
// acquisition and the 3-branch error triage — is identical and has always been patched in both.
async function request(mode: 'sync' | 'async', path: string, init: any[]): Promise<unknown> {
const label = mode === 'async' ? 'ASYNC ' : ''
console.log(`[rclone] ${label}REQUEST`, path, {
params: init[0]?.params,
body: init[0]?.body,
})
@@ -110,10 +112,11 @@ export default async function rclone<
throw new Error('Failed to get client after retries')
}
const result = await client.POST(
path,
...(init as InitParam<OpenApiMaybeOptionalInit<Paths[Path], 'post'>>)
)
const result = (
mode === 'async'
? await client.ASYNC(path as any, ...(init as [any]))
: await client.POST(path as any, ...(init as [any]))
) as RequestResult
if (result?.error) {
console.error('[rclone] ERROR', path, { error: result.error })
@@ -127,8 +130,7 @@ export default async function rclone<
const data = result.data as { error?: unknown } | undefined
if (data?.error) {
console.error('[rclone] DATA ERROR', path, { error: data.error })
const errMsg =
typeof data.error === 'string' ? data.error : JSON.stringify(data.error)
const errMsg = typeof data.error === 'string' ? data.error : JSON.stringify(data.error)
await handleReconnectIfNeeded(errMsg)
throw new Error(errMsg)
@@ -142,12 +144,12 @@ export default async function rclone<
throw new Error(`${result.response.status} ${result.response.statusText}`)
}
console.log('[rclone] RESPONSE', path, { hasData: !!result.data })
console.log(`[rclone] ${label}RESPONSE`, path, { hasData: !!result.data })
return result.data as OpenApiMethodResponse<typeof client, 'post', Path, Init>
return result.data
}
export async function rcloneAsync<
export default async function rclone<
Path extends OpenApiClientPathsWithMethod<RCDClient, 'post'>,
Init extends OpenApiMaybeOptionalInit<Paths[Path], 'post'> = OpenApiMaybeOptionalInit<
Paths[Path],
@@ -156,51 +158,21 @@ export async function rcloneAsync<
>(
path: Path,
...init: InitParam<Init>
): Promise<AsyncJobResponse> {
console.log('[rclone] ASYNC REQUEST', path, {
params: init[0]?.params,
body: init[0]?.body,
})
const client = await pRetry(() => getClient(), {
'maxTimeout': 500,
})
if (!client) {
console.error('[rclone] ERROR: Failed to get client after retries', path)
throw new Error('Failed to get client after retries')
}
const result = await client.ASYNC(path, ...(init as [any]))
if (result?.error) {
console.error('[rclone] ERROR', path, { error: result.error })
const errMsg =
typeof result.error === 'string' ? result.error : JSON.stringify(result.error)
await handleReconnectIfNeeded(errMsg)
throw new Error(errMsg)
}
const data = result.data as { error?: unknown } | undefined
if (data?.error) {
console.error('[rclone] DATA ERROR', path, { error: data.error })
const errMsg =
typeof data.error === 'string' ? data.error : JSON.stringify(data.error)
await handleReconnectIfNeeded(errMsg)
throw new Error(errMsg)
}
if (!result.response.ok) {
console.error('[rclone] HTTP ERROR', path, {
status: result.response.status,
statusText: result.response.statusText,
})
throw new Error(`${result.response.status} ${result.response.statusText}`)
}
console.log('[rclone] ASYNC RESPONSE', path, { hasData: !!result.data })
return result.data as AsyncJobResponse
): Promise<OpenApiMethodResponse<RCDClient, 'post', Path, Init>> {
return (await request('sync', path, init)) as OpenApiMethodResponse<
RCDClient,
'post',
Path,
Init
>
}
export async function rcloneAsync<
Path extends OpenApiClientPathsWithMethod<RCDClient, 'post'>,
Init extends OpenApiMaybeOptionalInit<Paths[Path], 'post'> = OpenApiMaybeOptionalInit<
Paths[Path],
'post'
>,
>(path: Path, ...init: InitParam<Init>): Promise<AsyncJobResponse> {
return (await request('async', path, init)) as AsyncJobResponse
}
+98 -168
View File
@@ -1,8 +1,6 @@
import { invoke } from '@tauri-apps/api/core'
import { appLocalDataDir, sep } from '@tauri-apps/api/path'
import { exists, mkdir, writeTextFile } from '@tauri-apps/plugin-fs'
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { Command } from '@tauri-apps/plugin-shell'
import createRCDClient from 'rclone-sdk'
import { exists, mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
import { useHostStore } from '../../store/host'
import type { FlagValue } from '../../types/rclone'
import { getConfigParentFolder } from '../format'
@@ -25,70 +23,29 @@ export async function getDefaultPaths() {
}
}
export async function getSystemConfigPath() {
console.log('[getSystemConfigPath] running system rclone')
const instance = Command.create('rclone-system', [
'rcd',
'--rc-no-auth',
'--rc-serve',
// '-rc-addr',
// ':5572',
])
if (!instance) {
console.error('[getSystemConfigPath] failed to create rclone instance')
throw new Error('Failed to create rclone instance, please try again later.')
}
const output = await instance.spawn()
console.log('[getSystemConfigPath] spawned rclone')
await new Promise((resolve) => setTimeout(resolve, 200))
try {
// no host store at this point
const client = createRCDClient({
baseUrl: 'http://localhost:5572',
fetch: (request: Request) => tauriFetch(request),
})
const defaultPaths = await client.POST('/config/paths', {})
const configPath = defaultPaths.data?.config
if (!configPath) {
throw new Error('Failed to fetch config path')
}
return configPath.replace(DOUBLE_BACKSLASH_REGEX, '\\')
} catch (error) {
console.error('[getSystemConfigPath] error', error)
if (error instanceof Error) {
throw error
}
throw new Error('Failed to get default path, please try again later.')
} finally {
await output.kill()
}
/** App-private location of the default config, used when there is no system rclone to defer to. */
export async function appPrivateDefaultConfigPath() {
const appLocalDataDirPath = await appLocalDataDir()
return appLocalDataDirPath + sep() + 'configs' + sep() + 'default' + sep() + 'rclone.conf'
}
export async function getConfigPath({ id, validate = true }: { id: string; validate?: boolean }) {
console.log('[getConfigPath]', id, validate)
const appLocalDataDirPath = await appLocalDataDir()
console.log('[getConfigPath] appLocalDataDirPath', appLocalDataDirPath)
let configPath = appLocalDataDirPath + sep() + 'configs' + sep() + id + sep() + 'rclone.conf'
console.log('[getConfigPath] configPath', configPath)
if (id == 'default' && (await isSystemRcloneInstalled())) {
const defaultPath = await getSystemConfigPath()
configPath = defaultPath
console.log('[getConfigPath] configPath', configPath)
// The "default" config lives at a location resolved once at adoption (native for a system
// rclone, app-private otherwise) and persisted, so switching binaries never moves remotes.
if (id === 'default') {
const persistedDefault = useHostStore.getState().defaultConfigPath
if (persistedDefault) {
configPath = persistedDefault
}
}
console.log('[getConfigPath] configPath', configPath)
if (validate) {
const configExists = await exists(configPath)
@@ -104,68 +61,102 @@ export async function getConfigPath({ id, validate = true }: { id: string; valid
export async function createConfigFile(path: string) {
console.log('[createConfigFile] path', path)
const hasConfig = await exists(path).catch(() => false)
console.log('[createConfigFile] hasConfig', hasConfig)
if (!hasConfig) {
console.log('[createConfigFile] writing space character to default path (1)', path)
if (await exists(path).catch(() => false)) {
return
}
try {
await writeTextFile(path, '# Empty config file\n')
} catch {
// Write-first, then create the parent dir on failure and retry. Do NOT mkdir first:
// getConfigParentFolder returns the path UNCHANGED for non-rclone.conf filenames, so an
// unconditional mkdir could create a directory at the config file path.
await mkdir(getConfigParentFolder(path), { recursive: true })
await writeTextFile(path, '# Empty config file\n')
}
}
/**
* Locates a genuine system rclone on PATH (excluding the app's own PATH-integration pointer).
* Returns null under Flatpak, where the host PATH is unreachable.
*/
export async function findSystemRclone(): Promise<string | null> {
try {
if (await invoke<boolean>('is_flatpak')) {
return null
}
return (await invoke<string | null>('find_system_rclone')) ?? null
} catch (error) {
console.error('[createConfigFile] error', error)
console.error('[findSystemRclone] error', error)
return null
}
}
if (!(await exists(path).catch(() => false))) {
console.log(
'[createConfigFile] failed to write space character to default path (1)',
path
)
const folderPath = getConfigParentFolder(path)
console.log('[createConfigFile] creating folder', folderPath)
await mkdir(folderPath, { recursive: true })
console.log('[createConfigFile] created folder', folderPath)
console.log('[createConfigFile] writing space character to default path (2)', path)
await writeTextFile(path, '# Empty config file\n')
const existsFinally = await exists(path).catch(() => false)
console.log('[createConfigFile] existsFinally', existsFinally)
/** Runs `<path> version` and returns the parsed version string; throws the detailed Rust error
* (including the macOS Gatekeeper `xattr` hint) when the binary is unusable. */
export async function probeRcloneBinaryOrThrow(path: string): Promise<string> {
return await invoke<string>('validate_rclone_binary', { path })
}
/** Like probeRcloneBinaryOrThrow, but returns null instead of throwing. */
export async function validateRcloneBinary(path: string): Promise<string | null> {
try {
return await probeRcloneBinaryOrThrow(path)
} catch (error) {
console.error('[validateRcloneBinary] error', error)
return null
}
}
export interface RcloneClassification {
kind: 'system' | 'managed' | 'custom'
version: string | null
}
/** Classifies a path as system / managed / custom using canonical comparisons in Rust. */
export async function classifyRclonePath(path: string): Promise<RcloneClassification> {
try {
return await invoke<RcloneClassification>('classify_rclone_path', { path })
} catch (error) {
console.error('[classifyRclonePath] error', error)
return { kind: 'custom', version: null }
}
}
/**
* Checks if rclone is installed and accessible from the system PATH
* @returns {Promise<boolean>} True if rclone is installed and working
* Resolves where the default config should live, driven by what the user already uses:
* an app-private config that already holds remotes wins; otherwise a system rclone's native
* config; otherwise the app-private default. Called once, then persisted.
*/
export async function isSystemRcloneInstalled() {
console.log('[isSystemRcloneInstalled]')
export async function resolveDefaultConfigPath(): Promise<string> {
const appPrivate = await appPrivateDefaultConfigPath()
try {
const output = await Command.create('rclone-system').execute()
return (
output.stdout.includes('Available commands') ||
output.stderr.includes('Available commands')
)
} catch {
return false
if (await exists(appPrivate)) {
const content = await readTextFile(appPrivate)
// A section header — or an encrypted body, which has no headers — means the user
// has real remotes here; keep them.
if (/^\s*\[/m.test(content) || content.includes('RCLONE_ENCRYPT_V0:')) {
return appPrivate
}
}
} catch (error) {
console.error('[resolveDefaultConfigPath] failed reading app-private config', error)
}
}
/**
* Checks if rclone is downloaded by the application in the app's local data directory
* @returns {Promise<boolean>} True if downloaded rclone is present and working
*/
export async function isInternalRcloneInstalled() {
console.log('[isInternalRcloneInstalled]')
const system = await findSystemRclone()
if (system) {
try {
const output = await Command.create('rclone-internal').execute()
// console.log('[isInternalRcloneInstalled] output', output)
return (
output.stdout.includes('Available commands') ||
output.stderr.includes('Available commands')
)
} catch {
return false
const native = await invoke<string>('rclone_config_path', { path: system })
if (native) {
return native.replace(DOUBLE_BACKSLASH_REGEX, '\\')
}
} catch (error) {
console.error('[resolveDefaultConfigPath] failed reading native config path', error)
}
}
return appPrivate
}
export function parseRcloneOptions(options: Record<string, FlagValue>) {
@@ -176,7 +167,10 @@ export function parseRcloneOptions(options: Record<string, FlagValue>) {
export function compareVersions(version1: string, version2: string): number {
const parseVersion = (version: string) => {
const parts = version.split('.').map((num) => Number.parseInt(num, 10))
// Strip a leading 'v' and any pre-release suffix (e.g. "1.74.0-beta.x") before comparing;
// otherwise parseInt('v1') is NaN → coerced to 0, silently mis-ordering versions.
const core = version.trim().replace(/^v/, '').split('-')[0]
const parts = core.split('.').map((num) => Number.parseInt(num, 10))
return {
major: parts[0] || 0,
minor: parts[1] || 0,
@@ -198,67 +192,3 @@ export function compareVersions(version1: string, version2: string): number {
}
return 0
}
const YOURS_VERSION_REGEX = /yours:\s+([^\s]+)/
const LATEST_VERSION_REGEX = /latest:\s+([^\s]+)/
export async function getRcloneVersion(type?: 'system' | 'internal') {
let instanceType = type
if (!instanceType) {
instanceType = (await isSystemRcloneInstalled()) ? 'system' : 'internal'
}
const result = await Command.create(
instanceType === 'system' ? 'rclone-system' : 'rclone-internal',
['selfupdate', '--check']
).execute()
const output = result.stdout.trim()
return parseRcloneVersion(output)
}
export function parseRcloneVersion(output: string) {
const yoursMatch = output.match(YOURS_VERSION_REGEX)
const latestMatch = output.match(LATEST_VERSION_REGEX)
if (!yoursMatch || !latestMatch) {
return null
}
return {
yours: yoursMatch[1],
latest: latestMatch[1],
}
}
export function shouldUpdateRclone(versionData: { yours: string; latest: string } | null) {
if (!versionData) {
console.warn('[shouldUpdateRclone] received no version data:', versionData)
return false
}
const currentVersion = versionData?.yours
const latestVersion = versionData?.latest
if (!currentVersion || !latestVersion) {
console.warn('[shouldUpdateRclone] could not parse version output:', versionData)
return false
}
console.log('[shouldUpdateRclone] current version:', currentVersion)
console.log('[shouldUpdateRclone] latest version:', latestVersion)
if (useHostStore.getState().lastSkippedVersion === latestVersion) {
console.log('[shouldUpdateRclone] latest version is in the lastSkippedVersion')
return false
}
// Compare versions using the existing compareVersions function
const versionComparison = compareVersions(currentVersion, latestVersion)
if (versionComparison < 0) {
console.log('[shouldUpdateRclone] internal rclone needs update')
return true
}
console.log('[shouldUpdateRclone] internal rclone is up to date')
return false
}
+6 -66
View File
@@ -20,6 +20,12 @@ export const RCLONE_CONFIG_DEFAULTS = {
export const RCLONE_CONF_REGEX = /[\/\\]rclone\.conf$/
export const DOUBLE_BACKSLASH_REGEX = /\\\\/g
// Minimum rclone version the app's RC surface requires. The Serve feature calls
// /serve/start|list|stop|stopall, which rclone added in 1.70.
export const MIN_RCLONE_VERSION = '1.70.0'
export const RCLONE_RELEASES_API = 'https://api.github.com/repos/rclone/rclone/releases?per_page=30'
export const RCLONE_RELEASES_SHOWN = 20
export const SERVE_TYPES = ['dlna', 'ftp', 'sftp', 'http', 'nfs', 'restic', 's3', 'webdav'] as const
export const SUPPORTS_CLEANUP = [
@@ -161,69 +167,3 @@ export function supportsPersistentEmptyFolders(backendType?: string | null) {
if (!backendType) return true
return !CANNOT_PERSIST_EMPTY_FOLDERS.includes(backendType.toLowerCase())
}
// export const SUPPORTED_OPERATIONS = [
// {
// id: 'uncategorized',
// name: 'Uncategorized',
// icon: <FileIcon />,
// titleColor: 'text-foreground',
// indicatorColor: 'text-foreground-500',
// },
// {
// id: 'copy',
// name: 'Copy',
// icon: <CopyIcon />,
// titleColor: 'text-primary-400',
// indicatorColor: 'text-primary-300',
// },
// {
// id: 'move',
// name: 'Move',
// icon: <MoveIcon />,
// titleColor: 'text-primary-400',
// indicatorColor: 'text-primary-300',
// },
// {
// id: 'delete',
// name: 'Delete',
// icon: <TrashIcon />,
// titleColor: 'text-danger-400',
// indicatorColor: 'text-danger-300',
// },
// {
// id: 'sync',
// name: 'Sync',
// icon: <ArrowRightLeftIcon />,
// titleColor: 'text-success-300',
// indicatorColor: 'text-success-300',
// },
// {
// id: 'bisync',
// name: 'Bisync',
// icon: <ArrowRightLeftIcon />,
// titleColor: 'text-primary-400',
// indicatorColor: 'text-primary-300',
// },
// {
// id: 'mount',
// name: 'Mount',
// icon: <HardDriveIcon />,
// titleColor: 'text-secondary-500',
// indicatorColor: 'text-secondary-400',
// },
// {
// id: 'purge',
// name: 'Purge',
// icon: <Trash2Icon />,
// titleColor: 'text-warning-300',
// indicatorColor: 'text-warning-300',
// },
// {
// id: 'serve',
// name: 'Serve',
// icon: <ServerIcon />,
// titleColor: 'text-cyan-500',
// indicatorColor: 'text-cyan-300',
// },
// ] as const
+257 -397
View File
@@ -1,144 +1,76 @@
import * as Sentry from '@sentry/browser'
import { invoke } from '@tauri-apps/api/core'
import { BaseDirectory, appLocalDataDir, appLogDir, sep } from '@tauri-apps/api/path'
import { tempDir } from '@tauri-apps/api/path'
import { appLogDir, sep } from '@tauri-apps/api/path'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { copyFile, exists, mkdir, readTextFile, remove } from '@tauri-apps/plugin-fs'
import { writeFile } from '@tauri-apps/plugin-fs'
import { exists, readTextFile } from '@tauri-apps/plugin-fs'
import { fetch } from '@tauri-apps/plugin-http'
import { platform } from '@tauri-apps/plugin-os'
import { exit, relaunch } from '@tauri-apps/plugin-process'
import { Command } from '@tauri-apps/plugin-shell'
import { useHostStore } from '../../store/host'
import { selectActiveConfigFile, useHostStore } from '../../store/host'
import { useStore } from '../../store/memory'
import { usePersistedStore } from '../../store/persisted'
import { getConfigParentFolder } from '../format'
import notify from '../notify'
import { openSmallWindow } from '../window'
import { ensureEncryptedConfigEnv } from './cli'
import { buildRcloneEnv } from './cli'
import {
classifyRclonePath,
compareVersions,
createConfigFile,
findSystemRclone,
getConfigPath,
getRcloneVersion,
getSystemConfigPath,
isInternalRcloneInstalled,
isSystemRcloneInstalled,
shouldUpdateRclone,
resolveDefaultConfigPath,
validateRcloneBinary,
} from './common'
import { downloadVersion, listDownloadedVersions } from './versions'
export async function initRclone(args: string[]) {
console.log('[initRclone] starting with args:', args)
const system = !(await invoke<boolean>('is_flatpak')) && (await isSystemRcloneInstalled())
console.log('[initRclone] system rclone installed:', system)
let internal = await isInternalRcloneInstalled()
console.log('[initRclone] internal rclone installed:', internal)
// Resolve which rclone binary to run (adopting a system/legacy binary on first launch).
let rclonePath = await resolveActiveRclone()
// rclone not available, let's download it
if (!system && !internal) {
console.log('[initRclone] no rclone installation found, provisioning...')
// Nothing installed anywhere — download the latest and adopt it.
if (!rclonePath) {
console.log('[initRclone] no rclone available, provisioning...')
useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' })
await openSmallWindow({
name: 'Startup',
url: '/startup',
})
const success = await provisionRclone()
console.log('[initRclone] provision rclone result:', success)
if (!success) {
const provisionedPath = await provisionRclone()
console.log('[initRclone] provision rclone result:', provisionedPath)
if (!provisionedPath) {
console.error('[initRclone] provision failed, setting fatal status')
useStore.setState({ startupStatus: 'fatal' })
return
}
console.log('[initRclone] provision succeeded')
usePersistedStore.getState().setRclonePath(provisionedPath)
rclonePath = provisionedPath
useStore.setState({ startupStatus: 'initialized' })
if (!['windows', 'macos'].includes(platform())) {
usePersistedStore.setState({ hideStartup: true })
}
internal = true
}
const rcloneVersion = await getRcloneVersion(system ? 'system' : 'internal')
console.log('[initRclone] rclone version:', rcloneVersion)
// Check for a newer stable release of a managed binary: auto-update or notify.
rclonePath = await maybeAutoUpdateRclone(rclonePath)
if (shouldUpdateRclone(rcloneVersion)) {
console.log('[initRclone] needs update')
useStore.setState({ startupStatus: 'updating' })
await openSmallWindow({
name: 'Startup',
url: '/startup',
// Keep the PATH-integration pointer aimed at the active binary (best-effort).
invoke('update_path_pointer', { targetPath: rclonePath }).catch((error) => {
console.warn('[initRclone] update_path_pointer failed', error)
})
try {
if (system) {
console.log('[initRclone] updating system rclone')
const code = (await invoke('update_system_rclone')) as number
console.log('[initRclone] update_rclone code', code)
if (code !== 0) {
console.log(
'[initRclone] system rclone update failed or was cancelled by user, code:',
code
)
useStore.setState({ startupStatus: 'error' })
const skipping = await ask(
'You are running an outdated version of the CLI that could not be updated.\n\nPlease update manually and restart Rclone UI.',
{
title: 'Error',
kind: 'error',
okLabel: 'Skip version',
cancelLabel: 'Exit',
}
)
console.log('[initRclone] user skipping version:', skipping)
if (skipping) {
console.log('[initRclone] saving skipped version:', rcloneVersion!.yours)
useHostStore.setState({ lastSkippedVersion: rcloneVersion!.yours })
}
} else {
console.log('[initRclone] system rclone updated successfully')
useStore.setState({ startupStatus: 'updated' })
}
}
if (internal) {
console.log('[initRclone] updating internal rclone')
const instance = Command.create('rclone-internal', ['selfupdate'])
const updateResult = await instance.execute()
console.log('[initRclone] updateResult', JSON.stringify(updateResult, null, 2))
if (updateResult.code !== 0) {
console.log(
'[initRclone] internal rclone update failed, code:',
updateResult.code
)
useStore.setState({ startupStatus: 'error' })
} else {
console.log('[initRclone] internal rclone updated successfully')
useStore.setState({ startupStatus: 'updated' })
}
}
} catch (error) {
console.error('[initRclone] failed to update rclone', error)
useStore.setState({ startupStatus: 'error' })
}
await new Promise((resolve) => setTimeout(resolve, 1000))
}
// Resolve + materialize the default config location once, independent of the binary,
// so switching binaries never relocates the user's remotes.
await ensureDefaultConfig()
const hostState = useHostStore.getState()
let configFiles = hostState.configFiles || []
console.log('[initRclone] loaded config files count:', configFiles.length)
let activeConfigFile = hostState.activeConfigFile
console.log('[initRclone] active config file:', activeConfigFile?.id)
if (system) {
const defaultPath = await getSystemConfigPath()
console.log('[initRclone] defaultPath', defaultPath)
await createConfigFile(defaultPath)
console.log('[initRclone] created system config file')
}
const existingDefaultConfig = configFiles.find((config) => config.id === 'default')
configFiles = configFiles.filter((config) => config.id !== 'default')
@@ -159,6 +91,10 @@ export async function initRclone(args: string[]) {
console.log('[initRclone] added default config to list')
useHostStore.setState({ configFiles })
// Resolve the active config against the REBUILT list so a persisted id of 'default' resolves.
let activeConfigFile = selectActiveConfigFile(useHostStore.getState())
console.log('[initRclone] active config file:', activeConfigFile?.id)
if (!activeConfigFile) {
console.log('[initRclone] no active config file, setting default')
activeConfigFile = configFiles[0]
@@ -168,13 +104,7 @@ export async function initRclone(args: string[]) {
}
console.log('[initRclone] set active config file to:', activeConfigFile.id)
useHostStore.setState({ activeConfigFile })
}
if (internal && activeConfigFile.id === 'default') {
console.log('[initRclone] creating internal default config file')
const defaultInternalPath = await getConfigPath({ id: 'default', validate: false })
await createConfigFile(defaultInternalPath)
useHostStore.getState().setActiveConfigFile(activeConfigFile.id!)
}
let configFolderPath = activeConfigFile.sync
@@ -203,11 +133,12 @@ export async function initRclone(args: string[]) {
okLabel: 'OK',
})
activeConfigFile = configFiles[0]
configFolderPath = getConfigParentFolder(
await getConfigPath({ id: 'default', validate: true })
)
// Rebind configPath too (not just configFolderPath): otherwise the readTextFile below
// reads the stale, known-missing synced path and the fallback dead-ends in an exit.
configPath = await getConfigPath({ id: 'default', validate: true })
configFolderPath = getConfigParentFolder(configPath)
console.log('[initRclone] switched to default config')
useHostStore.setState({ activeConfigFile: configFiles[0] })
useHostStore.getState().setActiveConfigFile(configFiles[0].id!)
}
}
@@ -227,36 +158,14 @@ export async function initRclone(args: string[]) {
} else {
console.log('[initRclone] no stored password configured')
}
if (!activeConfigFile.isEncrypted) {
console.log('[initRclone] updating config file encryption flag')
const updatedConfigFile = { ...activeConfigFile, isEncrypted: true }
const updatedConfigFiles = configFiles.map((config) =>
config.id === activeConfigFile!.id ? updatedConfigFile : config
)
useHostStore.setState({
configFiles: updatedConfigFiles,
activeConfigFile: updatedConfigFile,
})
console.log('[initRclone] saved updated encryption flag')
// Update activeConfigFile reference for the rest of the function
activeConfigFile = updatedConfigFile
}
} else if (activeConfigFile.isEncrypted) {
console.log('[initRclone] config file is not encrypted, clearing encryption flag')
const updatedConfigFile = { ...activeConfigFile, isEncrypted: false }
const updatedConfigFiles = configFiles.map((config) =>
config.id === activeConfigFile!.id ? updatedConfigFile : config
)
useHostStore.setState({
configFiles: updatedConfigFiles,
activeConfigFile: updatedConfigFile,
})
console.log('[initRclone] cleared encryption flag')
// Update activeConfigFile reference for the rest of the function
activeConfigFile = updatedConfigFile
// Reconcile the stored encryption flag with the file's actual contents. The local rebind
// is load-bearing: buildRcloneEnv below reads activeConfigFile to build the password env.
if (activeConfigFile.isEncrypted !== isEncrypted) {
console.log('[initRclone] reconciling encryption flag to', isEncrypted)
useHostStore.getState().updateConfigFile(activeConfigFile.id!, { isEncrypted })
activeConfigFile = { ...activeConfigFile, isEncrypted }
}
} catch (error) {
console.log('[initRclone] could not read config file', error)
@@ -273,10 +182,7 @@ export async function initRclone(args: string[]) {
return
}
const extraParams: { env: Record<string, string> } = {
env: {},
}
// Proxy connectivity check (informational; the env vars themselves are set by buildRcloneEnv).
if (hostState.proxy) {
console.log('[initRclone] proxy configured:', hostState.proxy.url)
try {
@@ -302,51 +208,29 @@ export async function initRclone(args: string[]) {
return
}
}
console.log('[initRclone] setting proxy environment variables')
extraParams.env.http_proxy = hostState.proxy.url
extraParams.env.https_proxy = hostState.proxy.url
extraParams.env.HTTP_PROXY = hostState.proxy.url
extraParams.env.HTTPS_PROXY = hostState.proxy.url
extraParams.env.no_proxy = hostState.proxy.ignoredHosts.join(',')
extraParams.env.NO_PROXY = hostState.proxy.ignoredHosts.join(',')
console.log(
'[initRclone] proxy env vars set, ignored hosts:',
hostState.proxy.ignoredHosts.length
)
}
if (internal || activeConfigFile.id !== 'default') {
console.log('[initRclone] setting custom config path:', configFolderPath)
extraParams.env.RCLONE_CONFIG_DIR = configFolderPath
extraParams.env.RCLONE_CONFIG = `${configFolderPath}${sep()}rclone.conf`
}
const commandName = system ? 'rclone-system' : internal ? 'rclone-internal' : null
if (activeConfigFile.isEncrypted && commandName) {
console.log('[initRclone] ensuring encrypted configuration access')
let env: Record<string, string>
try {
await ensureEncryptedConfigEnv(
activeConfigFile,
extraParams.env,
true,
commandName,
`Please enter the current password for "${activeConfigFile.label}"`
)
env = await buildRcloneEnv({
activeConfig: activeConfigFile,
configDirectory: configFolderPath,
configPath,
proxy: hostState.proxy,
rclonePath,
autoPromptForPassword: true,
})
} catch (error) {
if (error instanceof Error && error.message === 'Password prompt cancelled by user.') {
console.error('[initRclone] password prompt cancelled by user')
const response = await message(
'Password is required for encrypted configurations.',
{
const response = await message('Password is required for encrypted configurations.', {
title: 'Password Required',
kind: 'error',
buttons: {
cancel: 'Close',
ok: 'Try Again',
},
}
)
})
console.log('[initRclone] message response:', response)
if (response === 'Try Again') {
await relaunch()
@@ -357,246 +241,222 @@ export async function initRclone(args: string[]) {
}
throw error
}
}
console.log('[initRclone] extraParams', extraParams)
if (system) {
console.log('[initRclone] creating system rclone command instance')
const instance = Command.create('rclone-system', args, extraParams)
console.log('[initRclone] returning system rclone instance')
return { system: instance }
}
if (internal) {
console.log('[initRclone] creating internal rclone command instance')
const instance = Command.create('rclone-internal', args, extraParams)
console.log('[initRclone] returning internal rclone instance')
return { internal: instance }
}
console.error('[initRclone] no rclone installation available')
throw new Error('Failed to initialize rclone, please try again later.')
console.log('[initRclone] returning rclone command', { path: rclonePath, args })
return { path: rclonePath, args, env }
}
/**
* Downloads and provisions the latest version of rclone for the current platform
* @throws {Error} If architecture detection fails or installation is unsuccessful
* @returns {Promise<void>}
* Resolves the active rclone binary path: validates the persisted selection (self-healing a
* managed version whose absolute path moved), otherwise adopts a system / legacy / downloaded
* binary. Returns null when nothing is available so the caller can provision.
*/
export async function provisionRclone() {
console.log('[provisionRclone] starting provisioning process')
async function resolveActiveRclone(): Promise<string | null> {
const persisted = usePersistedStore.getState()
const stored = persisted.rclonePath
console.log('[provisionRclone] fetching latest version info')
const currentVersionString = await fetch('https://downloads.rclone.org/version.txt').then(
(res) => res.text()
if (stored) {
const version = await validateRcloneBinary(stored)
if (version) {
console.log('[resolveActiveRclone] using stored rclone', stored, version)
return stored
}
console.warn('[resolveActiveRclone] stored rclone path is unusable:', stored)
// Self-heal a managed version whose absolute path moved (e.g. home-dir rename).
const match = stored.match(/rclone-versions[/\\]v([^/\\]+)/)
if (match) {
const healed = await invoke<string | null>('managed_version_path', {
version: match[1],
})
if (healed && (await validateRcloneBinary(healed))) {
console.log('[resolveActiveRclone] self-healed managed path ->', healed)
persisted.setRclonePath(healed)
return healed
}
}
// fall through to the adoption ladder
}
// Fold any legacy single-slot binary into the versioned library first (idempotent), so it
// remains visible even when a system rclone ends up active.
let legacyAdopted: { version: string; path: string } | null = null
try {
legacyAdopted = await invoke<{ version: string; path: string } | null>(
'adopt_legacy_rclone'
)
console.log('[provisionRclone] currentVersionString', currentVersionString)
} catch (error) {
console.error('[resolveActiveRclone] adopt_legacy_rclone failed', error)
}
const currentVersion = currentVersionString.split('v')?.[1]?.trim()
// 1. Genuine system rclone — offered, not silently adopted, so the user decides whether the
// app tracks their system install or manages its own copy. Answering persists a path, so
// the question fires only while no usable path is stored.
const system = await findSystemRclone()
if (system) {
const systemVersion = await validateRcloneBinary(system)
if (systemVersion) {
const useSystem = await ask(
`Found rclone v${systemVersion} at:\n${system}\n\nUse it as the app's rclone? Otherwise the app will manage its own copy. You can switch anytime in Settings.`,
{
title: 'System rclone detected',
kind: 'info',
okLabel: 'Use system rclone',
cancelLabel: 'Manage separately',
}
)
if (useSystem) {
persisted.setRclonePath(system)
return system
}
}
}
if (!currentVersion) {
console.error('[provisionRclone] failed to get latest version from string')
// 2. The just-adopted legacy binary. Re-probe it: when the version already existed in the
// library, adopt_legacy_rclone returns that pre-existing binary without validating it.
if (legacyAdopted?.path && (await validateRcloneBinary(legacyAdopted.path))) {
persisted.setRclonePath(legacyAdopted.path)
return legacyAdopted.path
}
// 3. Newest already-downloaded managed version that still runs — a broken binary must fall
// through to provisioning instead of being re-adopted.
try {
const downloaded = await listDownloadedVersions()
for (const candidate of downloaded) {
if (await validateRcloneBinary(candidate.path)) {
persisted.setRclonePath(candidate.path)
return candidate.path
}
console.warn(
'[resolveActiveRclone] skipping unusable downloaded version:',
candidate.path
)
}
} catch (error) {
console.error('[resolveActiveRclone] list_downloaded_rclone_versions failed', error)
}
// 4. Nothing available — caller provisions.
return null
}
let rcloneUpdateChecked = false
/**
* For a managed binary: checks downloads.rclone.org for a newer stable release, once per app
* session (so switching versions in Settings doesn't immediately undo a pin). Downloads and
* adopts it when auto-update is on; otherwise notifies once per version that an update can be
* run from Settings. Never blocks startup on failure.
*/
async function maybeAutoUpdateRclone(currentPath: string): Promise<string> {
if (rcloneUpdateChecked) {
return currentPath
}
rcloneUpdateChecked = true
try {
const active = await classifyRclonePath(currentPath)
if (active.kind !== 'managed' || !active.version) {
return currentPath
}
const versionString = await fetch('https://downloads.rclone.org/version.txt', {
connectTimeout: 5000,
}).then((res) => res.text())
const latest = versionString.split('v')?.[1]?.trim()
if (!latest || compareVersions(latest, active.version) <= 0) {
return currentPath
}
const persisted = usePersistedStore.getState()
if (!persisted.autoUpdateRclone) {
if (persisted.lastNotifiedRcloneVersion !== latest) {
usePersistedStore.setState({ lastNotifiedRcloneVersion: latest })
await notify({
title: 'Rclone update available',
body: `rclone v${latest} is available. You can update from Settings → Binary.`,
})
}
return currentPath
}
console.log('[maybeAutoUpdateRclone] updating', active.version, '->', latest)
// Startup-window status only (never startupDisplayed): showStartup opens the window and,
// finding 'updated', shows the update message; a failed update is restored below so the
// window can't stick on 'updating' with no TAP TO START.
useStore.setState({ startupStatus: 'updating' })
const newPath = await downloadVersion(latest)
persisted.setRclonePath(newPath)
useStore.setState({ startupStatus: 'updated' })
return newPath
} catch (error) {
console.log('[maybeAutoUpdateRclone] update check skipped', error)
// Restore so 'updating' can't stick — but only if we set it: a failure before the
// download (classify, version fetch) must not downgrade a status another path already
// promoted (provisioning sets 'initialized' before this runs). When the Startup window
// is already open (provisioning path), restore 'initialized' — 'initializing' renders
// no TAP TO START and showStartup early-returns on startupDisplayed, stranding the
// window. Do NOT set 'error' — this path is offline-safe and silently continues on the
// existing binary.
const store = useStore.getState()
if (store.startupStatus === 'updating') {
useStore.setState({
startupStatus: store.startupDisplayed ? 'initialized' : 'initializing',
})
}
return currentPath
}
}
/** Resolves (once) and materializes the default config location for the active host. */
async function ensureDefaultConfig() {
const host = useHostStore.getState()
let defaultConfigPath = host.defaultConfigPath
if (!defaultConfigPath) {
defaultConfigPath = await resolveDefaultConfigPath()
console.log('[ensureDefaultConfig] resolved default config path', defaultConfigPath)
host.setDefaultConfigPath(defaultConfigPath)
}
await createConfigFile(defaultConfigPath)
}
/**
* Downloads the latest rclone release into the versioned library and returns its absolute path,
* or false on failure. The download/extract/verify pipeline lives in Rust.
*/
export async function provisionRclone(): Promise<string | false> {
console.log('[provisionRclone] starting')
let version: string | undefined
try {
const versionString = await fetch('https://downloads.rclone.org/version.txt').then((res) =>
res.text()
)
version = versionString.split('v')?.[1]?.trim()
} catch (error) {
console.error('[provisionRclone] failed to fetch latest version', error)
}
if (!version) {
await message('Failed to get latest rclone version, please try again later.')
return false
}
console.log('[provisionRclone] currentVersion', currentVersion)
console.log('[provisionRclone] latest version', version)
const currentPlatform = platform()
console.log('[provisionRclone] currentPlatform', currentPlatform)
const currentOs = currentPlatform === 'macos' ? 'osx' : currentPlatform
console.log('[provisionRclone] currentOs', currentOs)
console.log('[provisionRclone] getting temp directory path')
let tempDirPath = await tempDir()
if (tempDirPath.endsWith(sep())) {
tempDirPath = tempDirPath.slice(0, -1)
}
console.log('[provisionRclone] tempDirPath', tempDirPath)
console.log('[provisionRclone] detecting system architecture')
const arch = (await invoke('get_arch')) as 'arm64' | 'amd64' | '386' | 'unknown'
console.log('[provisionRclone] arch', arch)
if (arch === 'unknown') {
console.error('[provisionRclone] failed to get architecture')
await message('Failed to get current arch, please try again later.')
return false
}
const downloadUrl = `https://downloads.rclone.org/v${currentVersion}/rclone-v${currentVersion}-${currentOs}-${arch}.zip`
console.log('[provisionRclone] downloadUrl', downloadUrl)
console.log('[provisionRclone] downloading rclone binary')
const downloadedFile = await fetch(downloadUrl).then((res) => res.arrayBuffer())
console.log('[provisionRclone] download complete, size:', downloadedFile.byteLength)
console.log('[provisionRclone] checking if temp rclone directory exists')
let tempDirExists = false
let path: string
try {
tempDirExists = await exists('rclone', {
baseDir: BaseDirectory.Temp,
})
console.log('[provisionRclone] tempDirExists', tempDirExists)
path = await downloadVersion(version)
} catch (error) {
console.error('[provisionRclone] download failed', error)
Sentry.captureException(error)
console.error('[provisionRclone] failed to check if rclone temp dir exists', error)
}
if (tempDirExists) {
console.log('[provisionRclone] removing existing temp directory')
try {
await remove('rclone', {
recursive: true,
baseDir: BaseDirectory.Temp,
})
console.log('[provisionRclone] removed rclone temp dir')
} catch (error) {
Sentry.captureException(error)
console.error('[provisionRclone] failed to remove rclone temp dir', error)
await message('Failed to provision rclone.')
return false
}
}
console.log('[provisionRclone] creating temp directory')
try {
await mkdir('rclone', {
baseDir: BaseDirectory.Temp,
})
console.log('[provisionRclone] created rclone temp dir')
} catch (error) {
Sentry.captureException(error)
console.error('[provisionRclone] failed to create rclone temp dir', error)
await message('Failed to provision rclone.')
return false
}
const zipPath = [
tempDirPath,
'rclone',
`rclone-v${currentVersion}-${currentOs}-${arch}.zip`,
].join(sep())
console.log('[provisionRclone] zipPath', zipPath)
console.log('[provisionRclone] writing zip file to disk')
try {
await writeFile(zipPath, new Uint8Array(downloadedFile))
console.log('[provisionRclone] wrote zip file successfully')
} catch (error) {
Sentry.captureException(error)
console.error('[provisionRclone] failed to write zip file', error)
await message('Failed to provision rclone.')
return false
}
const extractPath = `${tempDirPath}${sep()}rclone${sep()}extracted`
console.log('[provisionRclone] extracting zip file to:', extractPath)
try {
await invoke('unzip_file', {
zipPath,
outputFolder: extractPath,
})
console.log('[provisionRclone] successfully unzipped file')
} catch (error) {
Sentry.captureException(error)
console.error('[provisionRclone] failed to unzip file', error)
await message('Failed to provision rclone.')
return false
}
const unarchivedPath = [
tempDirPath,
'rclone',
'extracted',
`rclone-v${currentVersion}-${currentOs}-${arch}`,
].join(sep())
console.log('[provisionRclone] unarchivedPath', unarchivedPath)
const binaryName = currentPlatform === 'windows' ? 'rclone.exe' : 'rclone'
console.log('[provisionRclone] binaryName', binaryName)
const rcloneBinaryPath = unarchivedPath + sep() + binaryName
console.log('[provisionRclone] rcloneBinaryPath', rcloneBinaryPath)
console.log('[provisionRclone] verifying extracted binary exists')
try {
const binaryExists = await exists(rcloneBinaryPath)
console.log('[provisionRclone] rcloneBinaryPathExists', binaryExists)
if (!binaryExists) {
console.error('[provisionRclone] binary not found in expected location')
throw new Error('Could not find rclone binary in zip')
}
} catch (error) {
Sentry.captureException(error)
console.error('[provisionRclone] failed to check if rclone binary exists', error)
await message('Failed to provision rclone.')
return false
}
console.log('[provisionRclone] getting app local data directory')
const appLocalDataDirPath = await appLocalDataDir()
console.log('[provisionRclone] appLocalDataDirPath', appLocalDataDirPath)
console.log('[provisionRclone] checking if app local data directory exists')
const appLocalDataDirPathExists = await exists(appLocalDataDirPath)
console.log('[provisionRclone] appLocalDataDirPathExists', appLocalDataDirPathExists)
if (!appLocalDataDirPathExists) {
console.log('[provisionRclone] creating app local data directory')
await mkdir(appLocalDataDirPath, {
recursive: true,
})
console.log('[provisionRclone] appLocalDataDirPath created')
}
const targetBinaryPath = `${appLocalDataDirPath}${sep()}${binaryName}`
console.log('[provisionRclone] targetBinaryPath', targetBinaryPath)
console.log('[provisionRclone] copying binary to final location')
const maxCopyRetries = 3
for (let attempt = 1; attempt <= maxCopyRetries; attempt++) {
console.log(`[provisionRclone] copy attempt ${attempt}/${maxCopyRetries}`)
try {
await copyFile(rcloneBinaryPath, targetBinaryPath)
console.log('[provisionRclone] copied rclone binary successfully')
break
} catch (copyError) {
console.log(
`[provisionRclone] attempt ${attempt}/${maxCopyRetries} failed to copy:`,
copyError
await message(
`Failed to download rclone: ${error instanceof Error ? error.message : String(error)}`
)
if (attempt < maxCopyRetries) {
const waitTime = attempt * 1000
console.log(`[provisionRclone] waiting ${waitTime}ms before retry`)
// Wait a bit before retrying
await new Promise((resolve) => setTimeout(resolve, waitTime))
} else {
console.error('[provisionRclone] all copy attempts failed', copyError)
Sentry.captureException(copyError, {
extra: {
rcloneBinaryPath,
targetBinaryPath,
},
})
throw new Error(
'Failed to provision rclone, file is busy. Install cli manually or try again later.'
)
}
}
return false
}
console.log('[provisionRclone] verifying installation')
const hasInstalled = await isInternalRcloneInstalled()
console.log('[provisionRclone] installation verified:', hasInstalled)
if (!hasInstalled) {
console.error('[provisionRclone] installation verification failed')
throw new Error('Failed to install rclone')
}
console.log('[provisionRclone] rclone has been installed successfully')
return true
console.log('[provisionRclone] installed at', path)
return path
}
+200
View File
@@ -0,0 +1,200 @@
import { invoke } from '@tauri-apps/api/core'
import { listen } from '@tauri-apps/api/event'
import { ask } from '@tauri-apps/plugin-dialog'
import { fetch } from '@tauri-apps/plugin-http'
import { useHostStore } from '../../store/host'
import { usePersistedStore } from '../../store/persisted'
import { restartActiveRclone } from './cli'
import rcloneClient from './client'
import { appPrivateDefaultConfigPath, compareVersions } from './common'
import { MIN_RCLONE_VERSION, RCLONE_RELEASES_API, RCLONE_RELEASES_SHOWN } from './constants'
export interface DownloadedVersion {
version: string
path: string
sizeBytes: number
}
export interface AvailableRelease {
version: string
publishedAt: string
}
export interface PathStatus {
enabled: boolean
target: string | null
warning: string | null
}
export interface DownloadProgress {
version: string
downloaded: number
total: number | null
}
export async function listDownloadedVersions(): Promise<DownloadedVersion[]> {
return await invoke<DownloadedVersion[]>('list_downloaded_rclone_versions')
}
/** Fetches stable rclone releases at or above the minimum supported version (best-effort). */
export async function fetchAvailableVersions(): Promise<AvailableRelease[]> {
const res = await fetch(RCLONE_RELEASES_API, {
headers: { Accept: 'application/vnd.github+json' },
})
if (!res.ok) {
throw new Error(`GitHub API responded ${res.status}`)
}
const releases = (await res.json()) as {
tag_name: string
prerelease: boolean
draft: boolean
published_at: string
}[]
return releases
.filter((r) => !r.prerelease && !r.draft)
.map((r) => ({ version: r.tag_name.replace(/^v/, ''), publishedAt: r.published_at }))
.filter((r) => compareVersions(r.version, MIN_RCLONE_VERSION) >= 0)
.sort((a, b) => compareVersions(b.version, a.version))
.slice(0, RCLONE_RELEASES_SHOWN)
}
/**
* Downloads a version into the managed library, forwarding progress events for the given version.
* Returns the absolute path of the installed binary.
*/
export async function downloadVersion(
version: string,
onProgress?: (progress: DownloadProgress) => void
): Promise<string> {
const unlisten = await listen<DownloadProgress>('rclone-download-progress', (event) => {
if (event.payload.version === version) {
onProgress?.(event.payload)
}
})
try {
const proxyUrl = useHostStore.getState().proxy?.url ?? null
return await invoke<string>('download_rclone_version', { version, proxyUrl })
} finally {
unlisten()
}
}
export async function deleteVersion(version: string): Promise<void> {
const activePath = usePersistedStore.getState().rclonePath ?? null
await invoke('delete_rclone_version', { version, activePath })
}
/** True if rclone currently has active transfers/checks or mounts. */
async function isRcloneBusy(): Promise<boolean> {
try {
const stats = (await rcloneClient('/core/stats')) as {
transferring?: unknown[]
checking?: unknown[]
}
if ((stats?.transferring?.length ?? 0) > 0 || (stats?.checking?.length ?? 0) > 0) {
return true
}
} catch (error) {
console.warn('[isRcloneBusy] core/stats failed', error)
}
try {
const mounts = (await rcloneClient('/mount/listmounts')) as { mountPoints?: unknown[] }
if ((mounts?.mountPoints?.length ?? 0) > 0) {
return true
}
} catch (error) {
console.warn('[isRcloneBusy] mount/listmounts failed', error)
}
return false
}
/**
* Points the app at `path` and restarts the daemon on it. Confirms first when transfers/mounts
* are active. Returns false if the user cancelled. With `offerSystemConfig`, offers adopting the
* binary's native config — after the busy confirm, so cancelling leaves no state behind.
*/
export async function activateRclonePath(
path: string,
opts?: { offerSystemConfig?: boolean }
): Promise<boolean> {
if (await isRcloneBusy()) {
const proceed = await ask(
'Transfers or mounts are in progress and will be interrupted by switching rclone. Continue?',
{
title: 'Rclone is busy',
kind: 'warning',
okLabel: 'Switch anyway',
cancelLabel: 'Cancel',
}
)
if (!proceed) {
return false
}
}
usePersistedStore.getState().setRclonePath(path)
// Called for its side effect: it persists the adopted default config path, which the restart
// snapshot below then reads back from the store.
if (opts?.offerSystemConfig) {
await maybeOfferSystemConfig(path)
}
try {
await invoke('update_path_pointer', { targetPath: path })
} catch (error) {
console.warn('[activateRclonePath] update_path_pointer failed', error)
}
await restartActiveRclone()
return true
}
/**
* When switching to the system rclone while the app's config is app-private, offer to adopt the
* system rclone's native config so the shell and app share remotes. Persists the adopted default
* config path (the zero-arg restart snapshot reads it back from the store); returns it, or null.
*/
async function maybeOfferSystemConfig(systemPath: string): Promise<string | null> {
const host = useHostStore.getState()
const appPrivate = await appPrivateDefaultConfigPath()
const current = host.defaultConfigPath
if (current && current !== appPrivate) {
return null // already using a non-app-private (likely native) config
}
try {
const native = await invoke<string>('rclone_config_path', { path: systemPath })
if (!native || native === current) {
return null
}
const useNative = await ask(
`Your app remotes are stored at:\n${current ?? appPrivate}\n\nThe system rclone uses:\n${native}\n\nWhich config should the app use?`,
{
title: 'Config location',
kind: 'info',
okLabel: 'Use system config',
cancelLabel: 'Keep app config',
}
)
if (useNative) {
host.setDefaultConfigPath(native)
return native
}
} catch (error) {
console.warn('[maybeOfferSystemConfig] failed', error)
}
return null
}
export async function getPathIntegration(): Promise<PathStatus> {
return await invoke<PathStatus>('get_rclone_path_integration')
}
export async function setPathIntegration(enable: boolean, targetPath: string): Promise<PathStatus> {
return await invoke<PathStatus>('set_rclone_path_integration', {
enable,
targetPath,
})
}
+2 -1
View File
@@ -9,6 +9,7 @@ import { openUrl } from '@tauri-apps/plugin-opener'
import { platform } from '@tauri-apps/plugin-os'
import { exit } from '@tauri-apps/plugin-process'
import { usePersistedStore } from '../store/persisted'
import { CLOSE_APP, emitToMain } from './events'
import { openWindow } from './window'
async function buildMenu() {
@@ -136,7 +137,7 @@ async function buildMenu() {
id: 'quit',
text: 'Quit',
action: async () => {
await getCurrentWindow().emit('close-app')
await emitToMain(CLOSE_APP)
},
})
menuItems.push(quitItem)
+1 -1
View File
@@ -11,7 +11,7 @@ export async function openFullWindow({
url: string
hideTitleBar?: boolean
}) {
console.log('[openFullWindow] ', name, url)
console.log('[openFullWindow]', name)
await invoke('open_full_window', { name, url, hideTitleBar })
return WebviewWindow.getByLabel(name)
}
+193 -225
View File
@@ -1,19 +1,19 @@
import * as Sentry from '@sentry/browser'
import { getVersion as getUiVersion } from '@tauri-apps/api/app'
import { invoke } from '@tauri-apps/api/core'
import { Channel, invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log'
import { platform } from '@tauri-apps/plugin-os'
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
import { exit, relaunch } from '@tauri-apps/plugin-process'
import type { Child } from '@tauri-apps/plugin-shell'
import { check } from '@tauri-apps/plugin-updater'
import { type Update, check } from '@tauri-apps/plugin-updater'
import { CronExpressionParser } from 'cron-parser'
import { defaultOptions } from 'tauri-plugin-sentry-api'
import { getDeepLinkUrl, handleDeepLinkUrl } from './lib/deep'
import { LOCAL_HOST_ID, getHostInfo } from './lib/hosts'
import { CLOSE_APP, RELAUNCH_APP, RESTART_RCLONE, type RestartRclonePayload } from './lib/events'
import { LOCAL_HOST_ID, RC_PORT, getHostInfo, makeLocalHost } from './lib/hosts'
import { validateLicense } from './lib/license'
import notify from './lib/notify'
import queryClient from './lib/query'
@@ -33,13 +33,35 @@ import { initRclone } from './lib/rclone/init'
import { initTray } from './lib/tray'
import { openSmallWindow } from './lib/window'
import { initHostStore, useHostStore } from './store/host'
import { waitForStoreHydration } from './store/lib'
import { useStore } from './store/memory'
import { usePersistedStore } from './store/persisted'
import { selectCurrentHost, usePersistedStore } from './store/persisted'
import type { ScheduledTask } from './types/schedules'
let currentRcloneChild: Child | null = null
let rcloneListenersRegistered = false
// Mirrors zookeeper.rs RcloneEvent — only 'close' is emitted.
type RcloneDaemonEvent = {
kind: 'close'
code: number | null
intentional: boolean
}
async function killRcloneDaemon() {
// Rust daemon state is authoritative: the command no-ops (returns false) when nothing is
// tracked, so we must not gate on a local mirror that could defeat its reload-orphan guard.
let killed = false
try {
killed = await invoke<boolean>('kill_rclone_daemon', {})
} catch (error) {
console.error('[killRcloneDaemon] failed to kill rclone daemon', error)
Sentry.captureException(error)
}
if (killed) {
await new Promise((resolve) => setTimeout(resolve, 1000))
}
}
try {
Sentry.init({
...defaultOptions,
@@ -98,19 +120,14 @@ async function checkFlatpakPermissions() {
async function waitForHydration() {
console.log('[waitForHydration] waiting for store hydration')
await new Promise((resolve) => setTimeout(resolve, 50))
if (!usePersistedStore.persist.hasHydrated()) {
await waitForHydration()
}
await waitForStoreHydration(() => usePersistedStore.persist.hasHydrated())
console.log('[waitForHydration] store hydrated')
}
async function initializeHostStore() {
console.log('[initializeHostStore] initializing')
const currentHost = usePersistedStore.getState().currentHost
// Default to 'local' if fresh install/no host selected
const hostId = currentHost?.id || 'local'
const hostId = usePersistedStore.getState().currentHostId || LOCAL_HOST_ID
await initHostStore(hostId)
@@ -120,7 +137,7 @@ async function initializeHostStore() {
async function checkHostReachability(): Promise<void> {
console.log('[checkHostReachability] checking host reachability')
const currentHost = usePersistedStore.getState().currentHost
const currentHost = selectCurrentHost(usePersistedStore.getState())
// If no host selected or local host, skip check (local rclone hasn't started yet)
if (!currentHost || currentHost.id === LOCAL_HOST_ID) {
@@ -164,13 +181,15 @@ async function checkHostReachability(): Promise<void> {
console.log('[checkHostReachability] retrying connection')
isReachable = await checkReachability()
} else {
// User chose to use local host
// User chose to use local host. Upsert the local host and point at it in one write so
// currentHostId never dangles.
console.log('[checkHostReachability] switching to local host')
const hosts = usePersistedStore.getState().hosts
const localHost = hosts.find((h) => h.id === LOCAL_HOST_ID)
if (localHost) {
usePersistedStore.setState({ currentHost: localHost })
}
usePersistedStore.setState((prev) => ({
hosts: prev.hosts.some((h) => h.id === LOCAL_HOST_ID)
? prev.hosts
: [...prev.hosts, makeLocalHost()],
currentHostId: LOCAL_HOST_ID,
}))
// Re-initialize host store for local
await initHostStore(LOCAL_HOST_ID)
return
@@ -226,8 +245,7 @@ async function checkAlreadyRunning() {
console.log('[checkAlreadyRunning]')
try {
const rcPort = 5572
const running = await invoke<boolean>('is_rclone_running', { port: rcPort })
const running = await invoke<boolean>('is_rclone_running', { port: RC_PORT })
console.log('[checkAlreadyRunning] running', running)
if (running) {
@@ -278,24 +296,26 @@ async function registerRcloneWindowListeners() {
const window = getCurrentWindow()
await window.listen('close-app', async () => {
console.log('[registerRcloneWindowListeners] close-app requested')
const transfers = await queryClient.ensureQueryData({
// Kill the daemon BEFORE exit/relaunch — this ordering is the entire point of these listeners.
const shutdown = async (mode: 'quit' | 'relaunch') => {
// A dead daemon means "no active transfers": don't let a listTransfers throw make quit a
// silent no-op.
const transfers = await queryClient
.ensureQueryData({
queryKey: ['transfers', 'list', 'all'],
queryFn: async () => await listTransfers(),
staleTime: 10_000, // 10 seconds
gcTime: 60_000, // 1 minute
})
.catch(() => null)
if (transfers?.active && transfers.active.length > 0) {
const answer = await ask('All active transfers will be stopped.', {
title: 'Exit',
kind: 'info',
okLabel: 'Quit',
okLabel: mode === 'relaunch' ? 'Relaunch' : 'Quit',
cancelLabel: 'Cancel',
})
if (!answer) {
return
}
@@ -304,84 +324,61 @@ async function registerRcloneWindowListeners() {
const cloudflaredTunnel = useStore.getState().cloudflaredTunnel
if (cloudflaredTunnel) {
try {
console.log('[close-app] stopping cloudflared tunnel')
console.log('[shutdown] stopping cloudflared tunnel')
await invoke('stop_cloudflared_tunnel', { pid: cloudflaredTunnel.pid })
useStore.setState({ cloudflaredTunnel: null })
} catch (error) {
console.error('[close-app] failed to stop cloudflared tunnel', error)
console.error('[shutdown] failed to stop cloudflared tunnel', error)
}
}
const child = currentRcloneChild
if (child) {
try {
await child.kill()
} catch (error) {
console.error('[close-app] failed to kill rclone child', error)
Sentry.captureException(error)
}
currentRcloneChild = null
await new Promise((resolve) => setTimeout(resolve, 1000))
}
await killRcloneDaemon()
if (mode === 'relaunch') {
await relaunch()
} else {
await exit(0)
}
}
await window.listen(CLOSE_APP, async () => {
console.log('[registerRcloneWindowListeners] close-app requested')
await shutdown('quit')
})
console.log('[registerRcloneWindowListeners] close-app listener registered')
await window.listen('relaunch-app', async () => {
await window.listen(RELAUNCH_APP, async () => {
console.log('[registerRcloneWindowListeners] relaunch-app requested')
const transfers = await queryClient.ensureQueryData({
queryKey: ['transfers', 'list', 'all'],
queryFn: async () => await listTransfers(),
staleTime: 10_000, // 10 seconds
gcTime: 60_000, // 1 minute
})
if (transfers?.active && transfers.active.length > 0) {
const answer = await ask('All active transfers will be stopped.', {
title: 'Exit',
kind: 'info',
okLabel: 'Relaunch',
cancelLabel: 'Cancel',
})
if (!answer) {
return
}
}
const cloudflaredTunnel = useStore.getState().cloudflaredTunnel
if (cloudflaredTunnel) {
try {
console.log('[close-app] stopping cloudflared tunnel')
await invoke('stop_cloudflared_tunnel', { pid: cloudflaredTunnel.pid })
useStore.setState({ cloudflaredTunnel: null })
} catch (error) {
console.error('[close-app] failed to stop cloudflared tunnel', error)
}
}
const child = currentRcloneChild
if (child) {
try {
await child.kill()
} catch (error) {
console.error('[relaunch-app] failed to kill rclone child', error)
Sentry.captureException(error)
}
currentRcloneChild = null
await new Promise((resolve) => setTimeout(resolve, 1000))
}
await relaunch()
await shutdown('relaunch')
})
console.log('[registerRcloneWindowListeners] relaunch-app listener registered')
await window.listen('restart-rclone', async () => {
await window.listen<RestartRclonePayload>(RESTART_RCLONE, async (event) => {
console.log('[registerRcloneWindowListeners] restart-rclone requested')
// Trust the payload: the initiating webview's store writes may not have reached the main
// window yet. Apply BEFORE the in-flight guard so state isn't lost on a skipped restart.
// configFiles BEFORE activeConfigId (setActiveConfigFile resolves against state.configFiles
// and nulls on a miss). NEVER log the raw payload — it carries config `pass`.
const payload = event.payload
if (payload) {
if (payload.rclonePath) {
usePersistedStore.getState().setRclonePath(payload.rclonePath)
}
if (payload.defaultConfigPath) {
useHostStore.getState().setDefaultConfigPath(payload.defaultConfigPath)
}
if (payload.configFiles) {
useHostStore.setState({ configFiles: payload.configFiles })
}
if (payload.activeConfigId) {
useHostStore.getState().setActiveConfigFile(payload.activeConfigId)
}
if (payload.proxy !== undefined) {
useHostStore.setState({ proxy: payload.proxy })
}
}
if (useStore.getState().isRestartingRclone) {
console.log('[restart-rclone] restart already in progress, ignoring request')
return
@@ -390,18 +387,7 @@ async function registerRcloneWindowListeners() {
useStore.setState({ isRestartingRclone: true })
try {
const child = currentRcloneChild
if (child) {
try {
await child.kill()
} catch (error) {
console.error('[restart-rclone] failed to exit rclone process', error)
Sentry.captureException(error)
}
currentRcloneChild = null
await new Promise((resolve) => setTimeout(resolve, 1000))
}
await killRcloneDaemon()
await startRclone()
} catch (error) {
@@ -454,25 +440,28 @@ async function startRclone() {
return await exit(0)
}
const command = rclone?.system || rclone?.internal
if (!command) {
if (!rclone) {
console.error('[startRclone] initRclone returned without a runnable command')
Sentry.captureException(new Error('initRclone returned without a runnable command.'))
return
}
command.addListener('close', async (event) => {
console.log('close', event)
currentRcloneChild = null
const { path, args: rcloneArgs, env } = rclone
const channel = new Channel<RcloneDaemonEvent>()
channel.onmessage = async (payload) => {
console.log('[startRclone] daemon close', payload)
// Killed intentionally (restart / quit) — the initiator handles what happens next.
if (payload.intentional) {
return
}
if (platform() === 'windows') {
return await exit(0)
}
console.log('event.code', event.code)
if (event.code === 143 || event.code === 1) {
if (payload.code === 143 || payload.code === 1) {
Sentry.captureException(new Error('Rclone has crashed'))
const confirmed = await ask('Rclone has crashed', {
title: 'Error',
@@ -485,16 +474,37 @@ async function startRclone() {
}
await relaunch()
}
})
}
command.addListener('error', (event) => {
console.log('error', event)
console.log('[startRclone] spawning rclone daemon')
let pid: number
try {
pid = await invoke<number>('spawn_rclone', {
path,
args: rcloneArgs,
env,
onEvent: channel,
})
console.log('[startRclone] starting rclone')
const childProcess = await command.spawn()
currentRcloneChild = childProcess
console.log('[startRclone] running rclone')
} catch (error) {
console.error('[startRclone] failed to spawn rclone daemon', error)
Sentry.captureException(error)
// A relaunch re-runs the resolution ladder, which can heal a broken binary.
const confirmed = await ask(
`Rclone failed to start: ${error instanceof Error ? error.message : String(error)}`,
{
title: 'Error',
kind: 'error',
okLabel: 'Relaunch',
cancelLabel: 'Exit',
}
)
if (confirmed) {
await relaunch()
return
}
return await exit(0)
}
console.log('[startRclone] running rclone, pid', pid)
await new Promise((resolve) => setTimeout(resolve, 500))
}
@@ -593,7 +603,14 @@ async function showStartup() {
}
console.log('[showStartup] startup not displayed, setting displayed and status')
useStore.setState({ startupDisplayed: true, startupStatus: 'initialized' })
// Upgrade-only: a successful auto-update's 'updated' status must survive so its message shows;
// everything else (normal launch with null status, or a failed update restored to
// 'initializing') becomes 'initialized'. Never unconditionally clobber, or 'updated' is lost.
const currentStartupStatus = useStore.getState().startupStatus
useStore.setState({
startupDisplayed: true,
startupStatus: currentStartupStatus === 'updated' ? 'updated' : 'initialized',
})
console.log('[showStartup] store updated with startup displayed and status set')
await openSmallWindow({
name: 'Startup',
@@ -617,7 +634,7 @@ async function resumeTasks() {
}
const scheduledTasks = useHostStore.getState().scheduledTasks
const activeConfigId = useHostStore.getState().activeConfigFile?.id
const activeConfigId = useHostStore.getState().activeConfigId
console.log('[resumeTasks] found', scheduledTasks.length, 'scheduled tasks')
console.log('[resumeTasks] activeConfigId:', activeConfigId)
@@ -892,6 +909,45 @@ async function handleTask(task: ScheduledTask) {
}
}
async function installUpdate(update: Update, required: boolean) {
const confirmed = await ask(
'You are running an outdated version of Rclone UI. Please update to the latest version.',
{
title: required ? 'Update Required' : 'Update Available',
kind: 'info',
okLabel: 'Update',
cancelLabel: required ? 'Exit' : 'Cancel',
}
)
if (!confirmed) {
console.log('[checkVersion] user cancelled update')
if (required) {
return await exit(0)
}
return
}
console.log('[checkVersion] downloading and installing update')
await update.downloadAndInstall()
console.log('[checkVersion] update downloaded and installed')
await message('Rclone UI has been updated. Please restart the application.', {
title: 'Update Complete',
kind: 'info',
okLabel: 'Restart',
})
console.log('[checkVersion] relaunching app')
// Direct relaunch: at checkVersion time no daemon exists yet (before startRclone), so the
// shutdown path is unnecessary; the old emit fired before any listener existed and was
// dropped, leaving the app running the old version.
await relaunch()
}
async function checkVersion() {
console.log('[checkVersion]')
@@ -939,70 +995,10 @@ async function checkVersion() {
if (compareVersions(currentVersion, minimumVersion) < 0) {
console.log('[checkVersion] currentVersion is outdated')
const confirmed = await ask(
'You are running an outdated version of Rclone UI. Please update to the latest version.',
{
title: 'Update Required',
kind: 'info',
okLabel: 'Update',
cancelLabel: 'Exit',
}
)
if (!confirmed) {
console.log('[checkVersion] user cancelled update')
return await exit(0)
}
console.log('[checkVersion] downloading and installing update')
await receivedUpdate.downloadAndInstall()
console.log('[checkVersion] update downloaded and installed')
await message('Rclone UI has been updated. Please restart the application.', {
title: 'Update Complete',
kind: 'info',
okLabel: 'Restart',
})
console.log('[checkVersion] relaunching app')
await getCurrentWindow().emit('relaunch-app')
await installUpdate(receivedUpdate, true)
} else if (compareVersions(currentVersion, okVersion) < 0) {
console.log('[checkVersion] checking for update')
const confirmed = await ask(
'You are running an outdated version of Rclone UI. Please update to the latest version.',
{
title: 'Update Available',
kind: 'info',
okLabel: 'Update',
cancelLabel: 'Cancel',
}
)
if (!confirmed) {
console.log('[checkVersion] user cancelled update')
return
}
console.log('[checkVersion] downloading and installing update')
await receivedUpdate.downloadAndInstall()
console.log('[checkVersion] update downloaded and installed')
await message('Rclone UI has been updated. Please restart the application.', {
title: 'Update Complete',
kind: 'info',
okLabel: 'Restart',
})
console.log('[checkVersion] relaunching app')
await getCurrentWindow().emit('relaunch-app')
await installUpdate(receivedUpdate, false)
}
} catch (error) {
console.error('[checkVersion] error', error)
@@ -1011,17 +1007,7 @@ async function checkVersion() {
}
async function checkRclone() {
let currentHost = usePersistedStore.getState().currentHost
if (!currentHost) {
currentHost = {
id: 'local',
name: 'Local Machine',
url: 'http://localhost:5572',
os: 'linux',
cliVersion: 'unknown',
}
}
let currentHost = selectCurrentHost(usePersistedStore.getState()) ?? makeLocalHost()
let hostInfo = await getHostInfo({
url: currentHost.url,
@@ -1037,13 +1023,7 @@ async function checkRclone() {
kind: 'error',
}
)
currentHost = {
id: 'local',
name: 'Local Machine',
url: 'http://localhost:5572',
os: 'linux',
cliVersion: 'unknown',
}
currentHost = makeLocalHost()
hostInfo = await getHostInfo({
url: currentHost.url,
@@ -1078,9 +1058,9 @@ async function checkRclone() {
console.log('[checkRclone] setting currentHost', currentHost)
usePersistedStore.setState({ currentHost })
usePersistedStore.setState((prev) => ({
hosts: [...prev.hosts.filter((h) => h.id !== currentHost!.id), currentHost],
hosts: [...prev.hosts.filter((h) => h.id !== currentHost.id), currentHost],
currentHostId: currentHost.id,
}))
}
@@ -1089,18 +1069,16 @@ getCurrentWindow().listen('tauri://close-requested', async () => {
await getCurrentWindow().destroy()
})
// maybe place this inside handleDeepLink?
function processDeepLink(url: string) {
const deepLinkUrl = getDeepLinkUrl(url)
console.log('deep link url', deepLinkUrl)
handleDeepLinkUrl(deepLinkUrl)
useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' })
}
onOpenUrl((urls) => {
console.log('deep links while running', urls)
const receivedUrl = urls[0]
const deepLinkUrl = getDeepLinkUrl(receivedUrl)
console.log('deep link url', deepLinkUrl)
handleDeepLinkUrl(deepLinkUrl)
useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' })
processDeepLink(urls[0])
})
async function handleDeepLink() {
@@ -1110,24 +1088,14 @@ async function handleDeepLink() {
console.log('[handleDeepLink] no deep links found')
return
}
console.log('[handleDeepLink] getting deep link url')
const deepLinkUrl = getDeepLinkUrl(urls[0])
console.log('[handleDeepLink] deep link url', deepLinkUrl)
console.log('[handleDeepLink] handling deep link url')
handleDeepLinkUrl(deepLinkUrl)
console.log('[handleDeepLink] deep link url handled')
console.log('[handleDeepLink] setting startup displayed and status')
useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' })
console.log('[handleDeepLink] startup displayed and status set')
processDeepLink(urls[0])
}
waitForHydration()
.then(() => checkFlatpakPermissions())
.then(() => initializeHostStore())
.then(() => checkHostReachability())
.then(() => registerRcloneWindowListeners())
.then(() => checkVersion())
.then(() => validateInstance())
.then(() => checkAlreadyRunning())
-10
View File
@@ -29,7 +29,6 @@
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-store": "^2.4.3",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-window-state": "^2.4.1",
@@ -5984,15 +5983,6 @@
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-shell": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
"integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tauri-apps/plugin-store": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.3.tgz",
-1
View File
@@ -52,7 +52,6 @@
"@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-store": "^2.4.3",
"@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-window-state": "^2.4.1",
+79 -54
View File
@@ -258,6 +258,7 @@ dependencies = [
"sentry",
"serde",
"serde_json",
"sha2",
"sysinfo",
"tar",
"tauri",
@@ -276,11 +277,12 @@ dependencies = [
"tauri-plugin-prevent-default",
"tauri-plugin-process",
"tauri-plugin-sentry",
"tauri-plugin-shell",
"tauri-plugin-single-instance",
"tauri-plugin-store",
"tauri-plugin-updater",
"tinyfiledialogs-rs",
"windows-sys 0.59.0",
"winreg 0.52.0",
"x11rb",
"zbus",
"zip 0.6.6",
@@ -5294,44 +5296,12 @@ dependencies = [
"digest",
]
[[package]]
name = "shared_child"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
dependencies = [
"libc",
"sigchld",
"windows-sys 0.60.2",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "sigchld"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1"
dependencies = [
"libc",
"os_pipe",
"signal-hook",
]
[[package]]
name = "signal-hook"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
dependencies = [
"libc",
"signal-hook-registry",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -6101,27 +6071,6 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-shell"
version = "2.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b"
dependencies = [
"encoding_rs",
"log",
"open",
"os_pipe",
"regex",
"schemars 0.8.22",
"serde",
"serde_json",
"shared_child",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"tokio",
]
[[package]]
name = "tauri-plugin-single-instance"
version = "2.4.2"
@@ -7599,6 +7548,15 @@ dependencies = [
"windows-targets 0.42.2",
]
[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
dependencies = [
"windows-targets 0.48.5",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -7650,6 +7608,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2",
]
[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
dependencies = [
"windows_aarch64_gnullvm 0.48.5",
"windows_aarch64_msvc 0.48.5",
"windows_i686_gnu 0.48.5",
"windows_i686_msvc 0.48.5",
"windows_x86_64_gnu 0.48.5",
"windows_x86_64_gnullvm 0.48.5",
"windows_x86_64_msvc 0.48.5",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
@@ -7707,6 +7680,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
@@ -7725,6 +7704,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
@@ -7743,6 +7728,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
@@ -7773,6 +7764,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
@@ -7791,6 +7788,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
@@ -7809,6 +7812,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
@@ -7827,6 +7836,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
@@ -7872,6 +7887,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "winreg"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
dependencies = [
"cfg-if",
"windows-sys 0.48.0",
]
[[package]]
name = "winreg"
version = "0.55.0"
+5 -1
View File
@@ -24,7 +24,6 @@ log = "0.4"
tauri = { version = "2.11.1", features = [ "tray-icon", "image-ico",
"image-png", "config-json5" ] }
tauri-plugin-log = "2.8.0"
tauri-plugin-shell = "2.3.5"
tauri-plugin-dialog = "2.7.1"
tauri-plugin-fs = "2.5.1"
tauri-plugin-opener = "2.5.4"
@@ -50,6 +49,7 @@ tinyfiledialogs = { package = "tinyfiledialogs-rs", version = "3.21.3", features
tauri-plugin-deep-link = "2.4.9"
flate2 = "1.1.9"
tar = "0.4.45"
sha2 = "0.10"
[target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.26"
@@ -59,3 +59,7 @@ objc = "0.2"
zbus = { version = "5" }
x11rb = "0.13"
gtk = "0.18"
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.52"
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] }
-40
View File
@@ -168,46 +168,6 @@
"core:webview:allow-internal-toggle-devtools",
"core:webview:allow-get-all-webviews",
"clipboard-manager:allow-write-text",
"shell:default",
"shell:allow-kill",
"shell:allow-spawn",
"shell:allow-stdin-write",
"shell:allow-open",
{
"identifier": "shell:allow-execute",
"allow": [
{
"name": "rclone-system",
"cmd": "rclone",
"args": true
},
{
"name": "rclone-internal",
"cmd": "$APPLOCALDATA/rclone",
"args": true
},
{
"name": "umount",
"cmd": "umount",
"args": true
}
]
},
{
"identifier": "shell:allow-spawn",
"allow": [
{
"name": "rclone-system",
"cmd": "rclone",
"args": true
},
{
"name": "rclone-internal",
"cmd": "$APPLOCALDATA/rclone",
"args": true
}
]
},
"log:default",
"dialog:default",
{
-5
View File
@@ -18,7 +18,6 @@ pub fn make_transparent(window: &WebviewWindow) -> Result<(), tauri::Error> {
let ns_window: id = msg_send![webview_obj, window];
let bg_color = NSColor::colorWithSRGBRed_green_blue_alpha_(nil, 0.0, 0.0, 0.0, 0.0);
let _: id = msg_send![ns_window, setBackgroundColor: bg_color];
// let _: () = msg_send![ns_window, setIgnoresMouseEvents:true];
})?;
Ok(())
@@ -155,8 +154,6 @@ pub async fn open_window(
) -> Result<(), String> {
if let Some(existing) = app_handle.get_webview_window(&name) {
existing.set_focus().map_err(|e| e.to_string())?;
#[cfg(target_os = "linux")]
focus_window_linux(&app_handle, &existing);
return Ok(());
}
@@ -286,8 +283,6 @@ pub async fn open_small_window(
return Ok(());
}
let os = std::env::consts::OS;
let mut builder = WebviewWindowBuilder::new(&app_handle, &name, WebviewUrl::App(url.into()))
.title(&name)
.inner_size(800.0, 500.0)
+32 -171
View File
@@ -1,12 +1,10 @@
use machine_uid;
use sentry;
use std::fs::{self, File};
use std::path::Path;
use std::fs;
use sysinfo::System;
use tauri::{AppHandle, Emitter, Manager};
use tauri::{AppHandle, Manager};
use tauri_plugin_sentry;
use tinyfiledialogs as tfd;
use zip::ZipArchive;
#[path = "../common/shortcut.rs"]
mod shortcut;
@@ -14,6 +12,8 @@ mod shortcut;
#[path = "../common/window.rs"]
mod window;
mod zookeeper;
use shortcut::{
ensure_toolbar_window, set_toolbar_shortcut, show_toolbar_window, DEFAULT_TOOLBAR_SHORTCUT,
};
@@ -127,48 +127,7 @@ fn has_flatpak_permissions() -> bool {
false
}
#[tauri::command]
fn unzip_file(zip_path: &str, output_folder: &str) -> Result<(), String> {
// Open the zip file
let file = File::open(zip_path).map_err(|e| e.to_string())?;
// Create output directory if it doesn't exist
fs::create_dir_all(output_folder).map_err(|e| e.to_string())?;
// Create ZIP archive reader
let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?;
// Extract everything
for i in 0..archive.len() {
let mut file = archive.by_index(i).map_err(|e| e.to_string())?;
let outpath = Path::new(output_folder).join(file.name());
if file.name().ends_with('/') || file.name().ends_with('\\') {
fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
} else {
if let Some(p) = outpath.parent() {
fs::create_dir_all(p).map_err(|e| e.to_string())?;
}
let mut outfile = File::create(&outpath).map_err(|e| e.to_string())?;
std::io::copy(&mut file, &mut outfile).map_err(|e| e.to_string())?;
}
// Get and set permissions (Unix only)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = file.unix_mode() {
fs::set_permissions(&outpath, fs::Permissions::from_mode(mode))
.map_err(|e| e.to_string())?;
}
}
}
Ok(())
}
#[tauri::command]
async fn stop_pid(pid: u32, timeout_ms: Option<u64>) -> Result<(), String> {
pub(crate) async fn kill_pid(pid: u32, timeout_ms: Option<u64>) -> Result<(), String> {
let timeout = timeout_ms.unwrap_or(5000);
#[cfg(any(
@@ -325,7 +284,7 @@ async fn stop_rclone_processes(timeout_ms: Option<u64>) -> Result<u32, String> {
let mut stopped: u32 = 0;
for pid in pids {
match stop_pid(pid, Some(timeout)).await {
match kill_pid(pid, Some(timeout)).await {
Ok(()) => stopped += 1,
Err(_e) => {}
}
@@ -334,10 +293,6 @@ async fn stop_rclone_processes(timeout_ms: Option<u64>) -> Result<u32, String> {
Ok(stopped)
}
async fn prompt_password(title: String, message: String) -> Result<Option<String>, String> {
prompt_text(title, message, None, Some(true)).await
}
async fn prompt_text(
title: String,
message: String,
@@ -559,6 +514,7 @@ async fn start_cloudflared_tunnel(app: tauri::AppHandle) -> Result<(u32, String)
// Start cloudflared tunnel
let mut child = SysCommand::new(&cloudflared_path)
// keep in sync with RC_PORT in lib/hosts.ts
.args(&["tunnel", "--url", "http://localhost:5572"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
@@ -602,7 +558,7 @@ async fn start_cloudflared_tunnel(app: tauri::AppHandle) -> Result<(u32, String)
}
// If we didn't get a URL, kill the process and return error
let _ = stop_pid(pid, Some(2000)).await;
let _ = kill_pid(pid, Some(2000)).await;
Err("Failed to get tunnel URL from cloudflared".to_string())
}
@@ -611,7 +567,7 @@ async fn stop_cloudflared_tunnel(pid: u32) -> Result<(), String> {
use std::time::Duration;
// Cloudflared takes ~5s to gracefully shut down, so give it enough time
match stop_pid(pid, Some(6000)).await {
match kill_pid(pid, Some(6000)).await {
Ok(()) => Ok(()),
Err(e) => {
// Wait a bit for the process to fully terminate
@@ -752,119 +708,6 @@ async fn test_proxy_connection(proxy_url: String) -> Result<String, String> {
Err(last_error.unwrap_or_else(|| "All proxy tests failed".to_string()))
}
#[tauri::command]
async fn update_system_rclone() -> Result<i32, String> {
#[cfg(target_os = "macos")]
{
use std::process::Command as SysCommand;
fn quote_posix(value: &str) -> String {
let escaped = value.replace("'", "'\\''");
format!("'{}'", escaped)
}
let mut cmdline =
String::from("PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH; ");
cmdline.push_str(&quote_posix("rclone"));
cmdline.push(' ');
cmdline.push_str(&quote_posix("selfupdate"));
// Escape for embedding inside an AppleScript string literal
let applescript_cmd = cmdline.replace('\\', "\\\\").replace('"', "\\\"");
let prompt = "Rclone UI needs permission to run rclone selfupdate.";
let script = format!(
"do shell script \"{}\" with administrator privileges with prompt \"{}\"",
applescript_cmd,
prompt.replace('"', "\\\"")
);
let status = SysCommand::new("osascript")
.arg("-e")
.arg(script)
.status()
.map_err(|e| e.to_string())?;
return Ok(status.code().unwrap_or(0));
}
#[cfg(target_os = "linux")]
{
use std::process::Command as SysCommand;
let path_env =
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin";
// Try PolicyKit first (graphical auth prompt on most desktops)
let mut pkexec_args: Vec<String> = Vec::new();
pkexec_args.push("--description".to_string());
pkexec_args.push("Rclone UI needs to run rclone selfupdate".to_string());
pkexec_args.push("env".to_string());
pkexec_args.push(path_env.to_string());
pkexec_args.push("rclone".to_string());
pkexec_args.push("selfupdate".to_string());
match SysCommand::new("pkexec").args(&pkexec_args).status() {
Ok(status) => return Ok(status.code().unwrap_or(0)),
Err(_e) => {
// Fallback to sudo with custom prompt (works if the user has NOPASSWD or cached credentials)
let mut sudo_env = std::collections::HashMap::new();
sudo_env.insert("SUDO_PROMPT", "Rclone UI needs permission to run rclone selfupdate. Please enter your password: ");
let mut sudo_args: Vec<String> = Vec::new();
sudo_args.push("-n".to_string());
sudo_args.push("env".to_string());
sudo_args.push(path_env.to_string());
sudo_args.push("rclone".to_string());
sudo_args.push("selfupdate".to_string());
let status = SysCommand::new("sudo")
.envs(&sudo_env)
.args(&sudo_args)
.status()
.map_err(|e| e.to_string())?;
return Ok(status.code().unwrap_or(0));
}
}
}
#[cfg(target_os = "windows")]
{
use std::process::Command as SysCommand;
fn quote_ps(value: &str) -> String {
// PowerShell single-quote escaping: ' -> ''
format!("'{}'", value.replace('\'', "''"))
}
let file_path = quote_ps("rclone");
let arg_list = String::from("@('selfupdate')");
let ps_script = format!(
"$p = Start-Process -Verb RunAs -WindowStyle Hidden -PassThru -FilePath {file} -ArgumentList {args}; \n\
$p.WaitForExit();\n\
exit $p.ExitCode",
file = file_path,
args = arg_list
);
let status = SysCommand::new("powershell")
.args([
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
&ps_script,
])
.status()
.map_err(|e| e.to_string())?;
return Ok(status.code().unwrap_or(0));
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
Err("Unsupported platform".to_string())
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let client = sentry::init((
@@ -887,6 +730,9 @@ pub fn run() {
}
let mut app = builder
.manage::<zookeeper::SharedDaemonState>(std::sync::Mutex::new(
zookeeper::DaemonState::default(),
))
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_sentry::init_with_no_injection(&client))
.plugin(tauri_plugin_clipboard_manager::init())
@@ -903,21 +749,17 @@ pub fn run() {
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_log::Builder::new().build())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_prevent_default::debug())
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.invoke_handler(tauri::generate_handler![
unzip_file,
get_arch,
get_uid,
is_rclone_running,
stop_rclone_processes,
prompt,
stop_pid,
update_toolbar_shortcut,
show_toolbar,
update_system_rclone,
test_proxy_connection,
is_flatpak,
is_linux_mint,
@@ -929,7 +771,22 @@ pub fn run() {
unlock_windows,
start_cloudflared_tunnel,
stop_cloudflared_tunnel,
extract_tgz
extract_tgz,
zookeeper::exec_rclone,
zookeeper::spawn_rclone,
zookeeper::kill_rclone_daemon,
zookeeper::validate_rclone_binary,
zookeeper::rclone_config_path,
zookeeper::find_system_rclone,
zookeeper::classify_rclone_path,
zookeeper::list_downloaded_rclone_versions,
zookeeper::delete_rclone_version,
zookeeper::adopt_legacy_rclone,
zookeeper::managed_version_path,
zookeeper::download_rclone_version,
zookeeper::update_path_pointer,
zookeeper::get_rclone_path_integration,
zookeeper::set_rclone_path_integration
])
.setup(|app| {
#[cfg(target_os = "linux")]
@@ -962,6 +819,10 @@ pub fn run() {
}
}
// Reclaim leftover .tmp-* download staging dirs from an interrupted download. Runs
// once here (before any webview) so it can never race a live download.
zookeeper::sweep_versions_tmp(app.handle());
if let Err(err) = ensure_toolbar_window(&app.handle()) {
log::warn!("failed to prepare toolbar window: {}", err);
}
File diff suppressed because it is too large Load Diff
+5 -7
View File
@@ -18,6 +18,7 @@ import { mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
import { platform } from '@tauri-apps/plugin-os'
import { UploadIcon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { onErrorDialog } from '../../lib/errors'
import { getConfigPath } from '../../lib/rclone/common'
import { useHostStore } from '../../store/host'
import type { ConfigFile } from '../../types/config'
@@ -85,14 +86,11 @@ export default function ConfigCreateDrawer({
onSuccess: () => {
onClose()
},
onError: async (error) => {
console.error('[createConfig] failed to save config', error)
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
title: 'Failed to save config',
kind: 'error',
onError: onErrorDialog('Failed to save config', undefined, {
okLabel: 'OK',
})
},
capture: false,
log: ['[createConfig] failed to save config'],
}),
})
return (
+5 -7
View File
@@ -15,6 +15,7 @@ import { message } from '@tauri-apps/plugin-dialog'
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
import { platform } from '@tauri-apps/plugin-os'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { onErrorDialog } from '../../lib/errors'
import { getConfigPath } from '../../lib/rclone/common'
import { useHostStore } from '../../store/host'
@@ -78,14 +79,11 @@ export default function ConfigEditDrawer({
onSuccess: () => {
onClose()
},
onError: async (error) => {
console.error('[updateConfig] failed to save config', error)
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
title: 'Failed to save config',
kind: 'error',
onError: onErrorDialog('Failed to save config', undefined, {
okLabel: 'OK',
})
},
capture: false,
log: ['[updateConfig] failed to save config'],
}),
})
const initializeConfig = useCallback(async () => {
+5 -7
View File
@@ -17,6 +17,7 @@ import { exists, readTextFile } from '@tauri-apps/plugin-fs'
import { platform } from '@tauri-apps/plugin-os'
import { UploadIcon } from 'lucide-react'
import { useState } from 'react'
import { onErrorDialog } from '../../lib/errors'
import { useHostStore } from '../../store/host'
import type { ConfigFile } from '../../types/config'
@@ -70,14 +71,11 @@ export default function ConfigSyncDrawer({
onSuccess: () => {
onClose()
},
onError: async (error) => {
console.error('[createSyncConfig] failed to save config', error)
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
title: 'Failed to save config',
kind: 'error',
onError: onErrorDialog('Failed to save config', undefined, {
okLabel: 'OK',
})
},
capture: false,
log: ['[createSyncConfig] failed to save config'],
}),
})
return (
+5 -7
View File
@@ -12,6 +12,7 @@ import { useMutation } from '@tanstack/react-query'
import { message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import { useState } from 'react'
import { onErrorDialog } from '../../lib/errors'
import { getHostInfo } from '../../lib/hosts'
import { usePersistedStore } from '../../store/persisted'
@@ -83,13 +84,10 @@ export default function HostAddDrawer({
setForm(INITIAL_FORM_STATE)
onClose()
},
onError: async (error) => {
console.error('[addHost] failed', error)
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
title: 'Connection failed',
kind: 'error',
})
},
onError: onErrorDialog('Connection failed', undefined, {
capture: false,
log: ['[addHost] failed'],
}),
})
return (
+7 -1
View File
@@ -541,6 +541,9 @@ export default function OptionsSection({
lineHeight: 20,
paddingTop: 0,
textareaOffsetTop: 0,
// Tracked so textModel (which measures line wrapping) recomputes on horizontal resize —
// the ResizeObserver refreshes this state, giving the memo a dep that follows the DOM.
clientWidth: 0,
})
const [activeDecoration, setActiveDecoration] = useState<DecorationState | null>(null)
const [isSelectorOpen, setIsSelectorOpen] = useState(false)
@@ -576,12 +579,14 @@ export default function OptionsSection({
const baseRect = textareaBaseRef.current?.getBoundingClientRect()
const textareaRect = textarea.getBoundingClientRect()
const textareaOffsetTop = baseRect ? textareaRect.top - baseRect.top : 0
const clientWidth = textarea.clientWidth
setTextareaLayout((previous) => {
if (
previous.lineHeight === lineHeight &&
previous.paddingTop === paddingTop &&
previous.textareaOffsetTop === textareaOffsetTop
previous.textareaOffsetTop === textareaOffsetTop &&
previous.clientWidth === clientWidth
) {
return previous
}
@@ -590,6 +595,7 @@ export default function OptionsSection({
lineHeight,
paddingTop,
textareaOffsetTop,
clientWidth,
}
})
+5 -7
View File
@@ -8,6 +8,7 @@ import { message, open } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import { FolderOpen } from 'lucide-react'
import { startTransition, useCallback, useEffect, useState } from 'react'
import { onErrorDialog } from '../../lib/errors'
import { useFlags } from '../../lib/hooks'
import { lockWindows, unlockWindows } from '../../lib/window'
import { type RemoteConfig, useHostStore } from '../../store/host'
@@ -165,13 +166,10 @@ export default function RemoteAutoMountDrawer({
setButtonText('Save Changes')
}, 1200)
},
onError: async (error) => {
console.error('Failed to update remote:', error)
await message(error instanceof Error ? error.message : 'Unknown error occurred', {
title: 'Could not update remote',
kind: 'error',
})
},
onError: onErrorDialog('Could not update remote', 'Unknown error occurred', {
capture: false,
log: ['Failed to update remote:'],
}),
})
const setMountOnStart = useCallback(
+1
View File
@@ -109,6 +109,7 @@ export default function RemoteCreateDrawer({
kind: 'error',
}
)
return
}
await message(errorMessage, {
+10 -21
View File
@@ -1,10 +1,11 @@
import { Drawer, DrawerBody, DrawerContent, DrawerFooter, DrawerHeader, cn } from '@heroui/react'
import { Button, Select, SelectItem } from '@heroui/react'
import { useMutation, useQuery } from '@tanstack/react-query'
import { message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { useMemo, useState } from 'react'
import { onErrorDialog } from '../../lib/errors'
import { useRemoteConfig } from '../../lib/hooks'
import queryClient from '../../lib/query'
import rclone from '../../lib/rclone/client'
import { OVERRIDES } from '../../lib/rclone/overrides'
@@ -22,18 +23,7 @@ export default function RemoteEditDrawer({
const [config, setConfig] = useState<Record<string, any>>({})
const [showMoreOptions, setShowMoreOptions] = useState(false)
const remoteConfigQuery = useQuery({
queryKey: ['remote', remoteName, 'config'],
queryFn: async () => {
return await rclone('/config/get', {
params: {
query: {
name: remoteName,
},
},
})
},
})
const remoteConfigQuery = useRemoteConfig(remoteName)
const remoteConfig = useMemo(() => remoteConfigQuery.data, [remoteConfigQuery.data])
@@ -111,7 +101,9 @@ export default function RemoteEditDrawer({
return updatedRemoteConfig
},
onSuccess: async (updatedRemoteConfig) => {
await rclone('/fscache/clear').catch()
// Best-effort cache clear; a failure here must not reject onSuccess and leave the
// drawer stranded open after an otherwise-successful save.
await rclone('/fscache/clear').catch(() => null)
queryClient.setQueryData(
['remote', remoteName, 'config'],
(old?: typeof remoteConfig) => ({
@@ -121,13 +113,10 @@ export default function RemoteEditDrawer({
)
onClose()
},
onError: async (error) => {
console.error('Failed to update remote:', error)
await message(error instanceof Error ? error.message : 'Unknown error occurred', {
title: 'Could not update remote',
kind: 'error',
})
},
onError: onErrorDialog('Could not update remote', 'Unknown error occurred', {
capture: false,
log: ['Failed to update remote:'],
}),
})
// if (!remoteConfig) return null
+46 -102
View File
@@ -1,9 +1,9 @@
import { Tab, Tabs } from '@heroui/react'
import { useQueries, useQuery } from '@tanstack/react-query'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { type Dispatch, type SetStateAction, useEffect, useMemo, useRef } from 'react'
import { getRemoteName } from '../../lib/format'
import { remoteConfigQueryOptions } from '../../lib/hooks'
import rclone from '../../lib/rclone/client'
import type { FlagValue } from '../../types/rclone'
import OptionsSection from '../components/OptionsSection'
const IGNORED_OPTIONS = [
@@ -25,22 +25,23 @@ const IGNORED_OPTIONS = [
'sse_customer_key_md5',
]
// Pure view over the remotes option state owned by useOptionGroups: each tab renders the raw
// per-remote JSON doc and writes through; parsing/retention/reset semantics live in the hook.
export default function RemoteOptionsSection({
selectedRemotes,
remoteOptionsLocked,
remoteOptionsJsonString,
setRemoteOptionsJsonString,
remoteOptionsJson,
setRemoteOptionsJson,
reconcileRemotes,
setRemoteOptionsLocked,
}: {
selectedRemotes: string[]
remoteOptionsLocked: boolean
remoteOptionsJsonString: string
setRemoteOptionsJsonString: (value: string) => void
remoteOptionsJson: Record<string, string>
setRemoteOptionsJson: Dispatch<SetStateAction<Record<string, string>>>
reconcileRemotes: (remoteNames: string[], force?: boolean) => void
setRemoteOptionsLocked: (value: boolean) => void
}) {
const [optionsJsonStrings, setOptionsJsonStrings] = useState<Record<string, string>>({})
const [options, setOptions] = useState<Record<string, Record<string, FlagValue[]>>>({})
const backendsQuery = useQuery({
queryKey: ['backends'],
queryFn: async () => {
@@ -73,32 +74,16 @@ export default function RemoteOptionsSection({
)
const remoteConfigQueries = useQueries({
queries: uniqueRemotes.map((remote) => ({
queryKey: ['remote', remote, 'config', 'withName'],
queryFn: async () => {
const remoteConfig = await rclone('/config/get', {
params: {
query: {
name: remote,
},
},
})
return {
name: remote,
config: remoteConfig,
}
},
})),
queries: uniqueRemotes.map((remote) => remoteConfigQueryOptions(remote)),
})
const remoteConfigs = useMemo(
() =>
remoteConfigQueries
.map((query) => query.data)
.map((query, i) => ({ name: uniqueRemotes[i], config: query.data }))
.map((data) => {
if (!data) return null
const { config, name } = data
if (!config) return null
if (config.type === 's3') {
if (config.provider) {
@@ -149,81 +134,45 @@ export default function RemoteOptionsSection({
}
})
.filter(Boolean),
[remoteConfigQueries, backends]
[remoteConfigQueries, backends, uniqueRemotes]
)
// Report the current unique remote names so the hook can rebuild the tab strings when the
// remote count changes (prune on deselect, seed on addition, discard mid-edit invalid text).
// The first call after (re)mounting forces the rebuild: the old per-tab strings were child
// state destroyed on unmount, so a remount always rebuilt every tab from the last-valid doc.
const isFirstReconcile = useRef(true)
useEffect(() => {
console.log('[RemoteOptionsSection] optionsJsonStrings', optionsJsonStrings)
}, [optionsJsonStrings])
reconcileRemotes(uniqueRemotes, isFirstReconcile.current)
isFirstReconcile.current = false
}, [uniqueRemotes, reconcileRemotes])
useEffect(() => {
if (Object.keys(optionsJsonStrings).length === uniqueRemotes.length) {
console.log('[RemoteOptionsSection] optionsJsonStrings already set')
return
}
console.log(
'[RemoteOptionsSection] setting optionsJsonStrings, parsing remoteOptionsJsonString: ',
remoteOptionsJsonString
)
const parsed = JSON.parse(remoteOptionsJsonString) as Record<string, string>
console.log('[RemoteOptionsSection] setting optionsJsonStrings parsed', parsed)
const jsonStrings: Record<string, string> = uniqueRemotes.reduce(
(acc, curr) => {
console.log('[RemoteOptionsSection] curr', curr)
console.log('[RemoteOptionsSection] parsed[curr]', parsed[curr])
acc[curr] = parsed[curr] ?? '{}'
return acc
},
{} as Record<string, string>
)
console.log('[RemoteOptionsSection] setting optionsJsonStrings to: ', jsonStrings)
startTransition(() => {
setOptionsJsonStrings(jsonStrings)
})
}, [uniqueRemotes, optionsJsonStrings, remoteOptionsJsonString])
useEffect(() => {
const stringified = JSON.stringify(
Object.entries(options).reduce(
(acc, [r, o]) => {
acc[r] = JSON.stringify(o, null, 2)
return acc
},
{} as Record<string, string>
)
)
console.log('[RemoteOptionsSection] stringified', stringified)
startTransition(() => {
setRemoteOptionsJsonString(stringified)
})
}, [options, setRemoteOptionsJsonString])
// OptionsSection calls setOptionsJson on every keystroke, including mid-edit
// when the JSON is temporarily invalid. OptionsSection shows "Invalid JSON"
// inline via its own isJsonValid state. The try/catch here just skips the
// update so we keep the last valid parsed options until the user fixes the JSON.
useEffect(() => {
const newOptions: Record<string, Record<string, FlagValue[]>> = {}
for (const [r, o] of Object.entries(optionsJsonStrings)) {
try {
newOptions[r] = JSON.parse(o) as Record<string, FlagValue[]>
} catch {
return
}
}
startTransition(() => {
setOptions(newOptions)
})
}, [optionsJsonStrings])
return (
<Tabs
items={remoteConfigs.map((data) => ({
const tabItems = useMemo(
() =>
remoteConfigs.map((data) => ({
id: data.name,
label: data.name.toUpperCase(),
options: data.options,
config: data.config,
}))}
})),
[remoteConfigs]
)
const setOptionsJsonByRemote = useMemo(() => {
const map: Record<string, (json: string) => void> = {}
for (const data of remoteConfigs) {
map[data.name] = (json: string) =>
setRemoteOptionsJson((prev) => ({
...prev,
[data.name]: json,
}))
}
return map
}, [remoteConfigs, setRemoteOptionsJson])
return (
<Tabs
items={tabItems}
fullWidth={true}
variant="bordered"
destroyInactiveTabPanel={false}
@@ -232,13 +181,8 @@ export default function RemoteOptionsSection({
{(item) => (
<Tab key={item.id} title={item.label}>
<OptionsSection
optionsJson={optionsJsonStrings[item.id]}
setOptionsJson={(json) =>
setOptionsJsonStrings((prev) => ({
...prev,
[item.id]: json,
}))
}
optionsJson={remoteOptionsJson[item.id] ?? '{}'}
setOptionsJson={setOptionsJsonByRemote[item.id]}
globalOptions={item.config}
availableOptions={item.options}
isLocked={remoteOptionsLocked}
+9 -2
View File
@@ -17,6 +17,7 @@ import { format } from 'date-fns'
import { CalendarClockIcon } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { buildReadablePath } from '../../lib/format'
import { useNow } from '../../lib/hooks'
import { useHostStore } from '../../store/host'
import type { ScheduledTask } from '../../types/schedules'
import CronEditor from './CronEditor'
@@ -51,9 +52,15 @@ export default function ScheduleEditDrawer({
[selectedTask.args]
)
// The drawer stays mounted after close, so "the next 5 runs" must be re-anchored to the
// current time on every open (and kept fresh while open) — paused while closed.
const now = useNow(isOpen ? 30_000 : null)
const upcomingRuns = useMemo(() => {
try {
const parsed = CronExpressionParser.parse(cronExpression)
const parsed = CronExpressionParser.parse(cronExpression, {
currentDate: new Date(now),
})
const runs: Date[] = []
for (let i = 0; i < 5; i++) {
if (parsed.hasNext()) {
@@ -64,7 +71,7 @@ export default function ScheduleEditDrawer({
} catch {
return []
}
}, [cronExpression])
}, [cronExpression, now])
const hasChanges = useMemo(
() => cronExpression !== selectedTask.cron,
+2 -3
View File
@@ -31,6 +31,7 @@ import {
} from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { useDebounce } from 'use-debounce'
import { formatErrorMessage } from '../../lib/errors'
import {
FLAG_CATEGORIES,
getJsonKeyCount,
@@ -147,9 +148,7 @@ export default function TemplateAddDrawer({
},
onError: async (error) => {
await message(
error instanceof Error
? error.message
: 'Error saving template. Please check your options.',
formatErrorMessage(error, 'Error saving template. Please check your options.'),
{
title: 'Error',
kind: 'error',
+2 -3
View File
@@ -28,6 +28,7 @@ import {
WrenchIcon,
} from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { formatErrorMessage } from '../../lib/errors'
import {
FLAG_CATEGORIES,
getJsonKeyCount,
@@ -170,9 +171,7 @@ export default function TemplateEditDrawer({
},
onError: async (error) => {
await message(
error instanceof Error
? error.message
: 'Error saving template. Please check your options.',
formatErrorMessage(error, 'Error saving template. Please check your options.'),
{
title: 'Error',
kind: 'error',
+5 -9
View File
@@ -11,8 +11,8 @@ import {
useRef,
useState,
} from 'react'
import { remoteConfigQueryOptions } from '../../../lib/hooks'
import { supportsPublicLink } from '../../../lib/rclone/constants'
import rclone from '../../../lib/rclone/client'
import { useHostStore } from '../../../store/host.ts'
import FileList from './FileList'
import PanelToolbar, { type ToolbarButtons } from './PanelToolbar'
@@ -90,12 +90,7 @@ const FilePanel = forwardRef<
})
const remoteConfigQuery = useQuery({
queryKey: ['remote', nav.selectedRemote, 'config'],
queryFn: async () => {
return await rclone('/config/get', {
params: { query: { name: nav.selectedRemote! } },
})
},
...remoteConfigQueryOptions(nav.selectedRemote),
enabled: nav.isRemote,
})
@@ -181,12 +176,13 @@ const FilePanel = forwardRef<
}
}, [onDrop, nav.selectedRemote, nav.cwd])
// biome-ignore lint/correctness/useExhaustiveDependencies: <>
// getSelection is useCallback'd on [selectedPaths], so its identity alone tracks selection
// changes — no extra dep or suppression needed.
useEffect(() => {
if (onSelectionChange) {
onSelectionChange(nav.getSelection())
}
}, [nav.selectedPaths, onSelectionChange, nav.getSelection])
}, [onSelectionChange, nav.getSelection])
useEffect(() => {
if (onNavigate && nav.selectedRemote) {
+2 -12
View File
@@ -1,9 +1,8 @@
import { Button, Input, Tooltip, cn } from '@heroui/react'
import { useQuery } from '@tanstack/react-query'
import { platform } from '@tauri-apps/plugin-os'
import { CheckIcon, ChevronRightIcon, LaptopIcon, PencilIcon, StarIcon } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import rclone from '../../../lib/rclone/client.ts'
import { useRemoteConfig } from '../../../lib/hooks'
import type { RemoteString } from './types'
import { getPathSegments } from './utils'
@@ -25,16 +24,7 @@ export default function PathBreadcrumb({
const [isInputMode, setIsInputMode] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const remoteConfigQuery = useQuery({
queryKey: ['remote', remote, 'config'],
queryFn: async () => {
if (!remote || remote === 'UI_LOCAL_FS' || remote === 'UI_FAVORITES') return null
return await rclone('/config/get', {
params: { query: { name: remote } },
})
},
enabled: !!remote && remote !== 'UI_LOCAL_FS' && remote !== 'UI_FAVORITES',
})
const remoteConfigQuery = useRemoteConfig(remote)
const remoteType = remoteConfigQuery.data?.type
+2 -2
View File
@@ -10,7 +10,7 @@ import {
import { DownloadIcon, FileIcon as FileIconLucide, XIcon } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { formatBytes } from '../../../lib/format.ts'
import { usePersistedStore } from '../../../store/persisted.ts'
import { useCurrentHost } from '../../../store/persisted.ts'
import FileIcon, { getFileType, isPreviewable } from './FileIcon'
import type { Entry } from './types'
@@ -44,7 +44,7 @@ export default function PreviewDrawer({
item: Entry | null
onClose: () => void
}) {
const currentHost = usePersistedStore((state) => state.currentHost)
const currentHost = useCurrentHost()
const hostUrl = currentHost?.url
const authUser = currentHost?.authUser
const authPassword = currentHost?.authPassword
+2 -12
View File
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { platform } from '@tauri-apps/plugin-os'
import { StarIcon } from 'lucide-react'
import { useMemo } from 'react'
import { useRemoteConfig } from '../../../lib/hooks'
import rclone from '../../../lib/rclone/client.ts'
import type { AllowedKey, RemoteString } from './types'
import { getDiskIcon, getDiskLabel, shouldShowDisk } from './utils'
@@ -16,18 +17,7 @@ function RemoteButton({
onSelect: (remote: string) => void
isSelected: boolean
}) {
const remoteConfigQuery = useQuery({
queryKey: ['remote', remote, 'config'],
queryFn: async () => {
return await rclone('/config/get', {
params: {
query: {
name: remote,
},
},
})
},
})
const remoteConfigQuery = useRemoteConfig(remote)
const info = remoteConfigQuery.data ?? null
+7 -17
View File
@@ -1,27 +1,16 @@
import { useQuery } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core'
import { message } from '@tauri-apps/plugin-dialog'
import { useCallback, useMemo } from 'react'
import { reportError } from '../../../lib/errors'
import { getFsInfo } from '../../../lib/format'
import { useRemoteConfig } from '../../../lib/hooks'
import rclone from '../../../lib/rclone/client'
import { supportsPersistentEmptyFolders } from '../../../lib/rclone/constants'
import type { RemoteString } from './types'
import { RE_TRAILING_SEPARATORS } from './utils'
export default function useCreateFolder(
remote: RemoteString,
cwd: string,
refresh: () => void
) {
const remoteConfigQuery = useQuery({
queryKey: ['remote', remote, 'config'],
queryFn: async () => {
return await rclone('/config/get', {
params: { query: { name: remote! } },
})
},
enabled: !!remote && remote !== 'UI_LOCAL_FS' && remote !== 'UI_FAVORITES',
})
export default function useCreateFolder(remote: RemoteString, cwd: string, refresh: () => void) {
const remoteConfigQuery = useRemoteConfig(remote)
const backendType = useMemo(() => {
if (!remote || remote === 'UI_FAVORITES') return null
@@ -74,9 +63,10 @@ export default function useCreateFolder(
refresh()
} catch (error) {
await message(error instanceof Error ? error.message : 'Create folder failed', {
await reportError(error, {
title: 'Error',
kind: 'error',
fallback: 'Create folder failed',
capture: false,
})
}
}, [remote, cwd, refresh, canCreateFolder])
+26 -3
View File
@@ -319,14 +319,26 @@ export default function useFileNavigation({
setRefreshKey((k) => k + 1)
}, [selectedRemote, cwd])
// Initialize on first mount if no initial values provided
// Initialize once per activation. The guard is set inside the branches (the remotes branch
// only once the list has loaded, so late data can still finish the job) — after that, dep
// churn (e.g. a /config/listremotes refetch minting a new `remotes` identity) can no longer
// yank live navigation back to the initial location. Deliberately no effect cleanup:
// cancelling the pending homeDir() write would strand the panel on isLoading.
const hasInitializedRef = useRef(false)
useEffect(() => {
if (!isActive) return
if (!isActive) {
// Deactivation re-arms initialization so a closed-and-reopened drawer (PathSelector
// passes isActive={isOpen}) still resets to its initial location.
hasInitializedRef.current = false
return
}
if (hasInitializedRef.current) return
const hasInitial = initialRemote !== undefined
const needsLocalPath = initialRemote === 'UI_LOCAL_FS' && !initialPath
if (needsLocalPath || (!hasInitial && canShowLocal)) {
hasInitializedRef.current = true
setIsLoading(true)
homeDir().then((home) => {
startTransition(() => {
@@ -337,13 +349,23 @@ export default function useFileNavigation({
setIsLoading(false)
})
} else if (!hasInitial && canShowFavorites) {
hasInitializedRef.current = true
startTransition(() => setSelectedRemote('UI_FAVORITES'))
} else if (!hasInitial && canShowRemotes && remotes.length > 0) {
} else if (!hasInitial && canShowRemotes) {
// remotes still loading (empty list): stay uninitialized so the arrival re-run
// completes the initialization.
if (remotes.length > 0) {
hasInitializedRef.current = true
startTransition(() => {
setSelectedRemote(remotes[0])
setCwd('')
})
}
} else {
// hasInitial with a concrete remote/path: state was already seeded by the useState
// initializers; nothing to apply.
hasInitializedRef.current = true
}
}, [
isActive,
canShowLocal,
@@ -355,6 +377,7 @@ export default function useFileNavigation({
])
// Load directory content when remote/cwd changes
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey is an intentional re-run trigger the body doesn't read — refresh() evicts the cacheRef entry, clears items, and bumps it to force a refetch of the current directory; removing it breaks the Refresh button (empty panel, isLoading stuck true)
useEffect(() => {
if (!isActive) return
@@ -0,0 +1,213 @@
import {
Button,
ButtonGroup,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
Tooltip,
} from '@heroui/react'
import { platform } from '@tauri-apps/plugin-os'
import { AnimatePresence, motion } from 'framer-motion'
import { ClockIcon, EyeIcon } from 'lucide-react'
import { type ComponentProps, type ReactNode, useCallback, useMemo } from 'react'
import { openWindow } from '../../../lib/window'
import type { Template } from '../../../types/template'
import CommandInfoButton from '../CommandInfoButton'
import CommandsDropdown from '../CommandsDropdown'
import TemplatesDropdown from '../TemplatesDropdown'
/**
* The footer strip shared by the operation pages: TemplatesDropdown wiring, the AnimatePresence
* START/NEW swap with the three reset items, and the ButtonGroup (dry-run when the page has one,
* schedule, help, commands). Everything page-specific stays page-supplied: the reset onPress
* bodies, the start/dry-run gating condition (`startBlocked` the page's jsonError + path
* checks), button text/icon, the NEW label, and the help prose.
*/
export default function OperationFooter({
operation,
templatesDisabled,
onTemplateSelect,
getTemplateOptions,
startIsSuccess,
startIsPending,
onStart,
onSchedule,
dryRunIsPending,
onDryRun,
startBlocked,
buttonText,
buttonIcon,
newLabel,
newButtonPrimary = true,
showViewTransfers = true,
resetPathsLabel = 'Reset Paths',
onResetPaths,
onResetOptions,
onResetAll,
helpContent,
}: {
operation: Template['tags'][number]
templatesDisabled: boolean
onTemplateSelect: ComponentProps<typeof TemplatesDropdown>['onSelect']
getTemplateOptions: ComponentProps<typeof TemplatesDropdown>['getOptions']
startIsSuccess: boolean
startIsPending: boolean
onStart: () => void
onSchedule: () => void
// Present only on pages with a dry-run mutation (Copy/Sync/Move/Delete).
dryRunIsPending?: boolean
onDryRun?: () => void
startBlocked: boolean
buttonText: string
buttonIcon: ReactNode
newLabel: string
newButtonPrimary?: boolean
showViewTransfers?: boolean
resetPathsLabel?: string
onResetPaths: () => void
onResetOptions: () => void
onResetAll: () => void
helpContent: string
}) {
const dropdownShadow = useMemo(() => (platform() === 'windows' ? 'none' : undefined), [])
const handleStartPress = useCallback(() => {
setTimeout(() => onStart(), 100)
}, [onStart])
const handleDryRunPress = useCallback(() => {
if (dryRunIsPending || startBlocked) {
return
}
setTimeout(() => onDryRun?.(), 100)
}, [dryRunIsPending, startBlocked, onDryRun])
const handleSchedulePress = useCallback(() => {
setTimeout(() => onSchedule(), 100)
}, [onSchedule])
const handleViewTransfersPress = useCallback(async () => {
await openWindow({
name: 'Transfers',
url: '/transfers',
})
}, [])
return (
<>
<TemplatesDropdown
isDisabled={templatesDisabled}
operation={operation}
onSelect={onTemplateSelect}
getOptions={getTemplateOptions}
/>
<AnimatePresence mode="wait" initial={false}>
{startIsSuccess ? (
<motion.div
key="started-buttons"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex flex-1 gap-2"
>
<Dropdown shadow={dropdownShadow}>
<DropdownTrigger>
<Button
fullWidth={true}
size="lg"
color={newButtonPrimary ? 'primary' : undefined}
data-focus-visible="false"
>
{newLabel}
</Button>
</DropdownTrigger>
<DropdownMenu>
<DropdownItem key="reset-paths" onPress={onResetPaths}>
{resetPathsLabel}
</DropdownItem>
<DropdownItem key="reset-options" onPress={onResetOptions}>
Reset Options
</DropdownItem>
<DropdownItem key="reset-all" onPress={onResetAll}>
Reset All
</DropdownItem>
</DropdownMenu>
</Dropdown>
{showViewTransfers ? (
<Button
fullWidth={true}
size="lg"
color="secondary"
onPress={handleViewTransfersPress}
data-focus-visible="false"
>
VIEW TRANSFERS
</Button>
) : null}
</motion.div>
) : (
<motion.div
key="start-button"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex flex-1"
>
<Button
onPress={handleStartPress}
size="lg"
fullWidth={true}
type="button"
color="primary"
isDisabled={startIsPending || startBlocked}
isLoading={startIsPending}
endContent={buttonIcon}
className="max-w-2xl gap-2"
data-focus-visible="false"
>
{buttonText}
</Button>
</motion.div>
)}
</AnimatePresence>
<ButtonGroup variant="flat">
{onDryRun ? (
<Tooltip
content="Preview (Dry Run)"
placement="top"
size="lg"
color="foreground"
>
<Button
size="lg"
type="button"
color="primary"
isIconOnly={true}
isLoading={dryRunIsPending}
onPress={handleDryRunPress}
>
<EyeIcon className="size-6" />
</Button>
</Tooltip>
) : null}
<Tooltip content="Schedule task" placement="top" size="lg" color="foreground">
<Button
size="lg"
type="button"
color="primary"
isIconOnly={true}
onPress={handleSchedulePress}
>
<ClockIcon className="size-6" />
</Button>
</Tooltip>
<CommandInfoButton content={helpContent} />
<CommandsDropdown currentCommand={operation} />
</ButtonGroup>
</>
)
}
@@ -0,0 +1,114 @@
import { Accordion, AccordionItem, Avatar } from '@heroui/react'
import {
ClockIcon,
CopyIcon,
DiamondPercentIcon,
FilterIcon,
FolderSyncIcon,
MoveIcon,
ServerIcon,
WrenchIcon,
} from 'lucide-react'
import { type ComponentType, type ReactNode, useMemo } from 'react'
import ShowMoreOptionsBanner from '../ShowMoreOptionsBanner'
// Avatar/indicator/title per option category — exactly what each page's accordion rendered.
export const CATEGORY_META: Record<
'copy' | 'sync' | 'move' | 'bisync' | 'filters' | 'cron' | 'config' | 'remotes',
{
title: string
icon: ComponentType<{ className?: string }>
avatarColor?: 'primary' | 'success' | 'danger' | 'warning' | 'default'
avatarClassName?: string
avatarIconClassName?: string
}
> = {
copy: { title: 'Copy', icon: CopyIcon, avatarColor: 'primary' },
sync: { title: 'Sync', icon: FolderSyncIcon, avatarColor: 'success' },
move: { title: 'Move', icon: MoveIcon, avatarColor: 'primary' },
bisync: {
title: 'Bisync',
icon: DiamondPercentIcon,
avatarClassName: 'bg-lime-500',
avatarIconClassName: 'text-success-foreground',
},
filters: { title: 'Filters', icon: FilterIcon, avatarColor: 'danger' },
cron: { title: 'Cron', icon: ClockIcon, avatarColor: 'warning' },
config: { title: 'Config', icon: WrenchIcon, avatarColor: 'default' },
remotes: { title: 'Remotes', icon: ServerIcon, avatarClassName: 'bg-fuchsia-500' },
}
export type OptionCategory = keyof typeof CATEGORY_META
export interface OptionsAccordionItemDef {
key: string
category: OptionCategory
subtitle?: string
children: ReactNode
}
/**
* The option-group accordion shared by the operation pages: item scaffolding (Avatar,
* indicator, title) comes from CATEGORY_META; each item's content (OptionsSection /
* CronEditor / RemoteOptionsSection) stays page-supplied. `banner` wraps the accordion in the
* relative div with the ShowMoreOptionsBanner (Copy/Sync/Move); Bisync/Delete/Purge omit it.
*/
export default function OptionsAccordion({
items,
defaultExpandedKeys,
banner = false,
}: {
items: OptionsAccordionItemDef[]
defaultExpandedKeys?: string[]
banner?: boolean
}) {
const accordionItems = useMemo(
() =>
items.map((item) => {
const meta = CATEGORY_META[item.category]
const Icon = meta.icon
return (
<AccordionItem
key={item.key}
startContent={
<Avatar
color={meta.avatarColor}
className={meta.avatarClassName}
radius="lg"
fallback={<Icon className={meta.avatarIconClassName} />}
/>
}
indicator={<Icon />}
title={meta.title}
subtitle={item.subtitle}
>
{item.children}
</AccordionItem>
)
}),
[items]
)
const accordion = (
<Accordion
keepContentMounted={true}
dividerProps={{
className: 'opacity-50',
}}
defaultExpandedKeys={defaultExpandedKeys}
>
{accordionItems}
</Accordion>
)
if (!banner) {
return accordion
}
return (
<div className="relative flex flex-col">
{accordion}
<ShowMoreOptionsBanner />
</div>
)
}
@@ -0,0 +1,34 @@
import { useMutation } from '@tanstack/react-query'
import { ask } from '@tauri-apps/plugin-dialog'
import { onErrorDialog } from '../../../lib/errors'
import { openWindow } from '../../../lib/window'
/**
* The dry-run mutation shared by the operation pages that offer one (Copy/Sync/Move/Delete).
* The page supplies the whole mutationFn including its path validation and the per-page
* `config: { ...configOptions, dry_run: true }` merge, which must stay in the page so no page
* can silently lose the dry_run injection.
*/
export function useOperationDryRun(mutationFn: () => Promise<unknown>) {
return useMutation({
mutationFn,
onSuccess: async () => {
const result = await ask(
'Dry run started, you can check the results in the Transfers screen',
{
title: 'Preview (Dry Run)',
kind: 'info',
okLabel: 'Open Transfers',
cancelLabel: 'OK',
}
)
if (result) {
await openWindow({ name: 'Transfers', url: '/transfers' })
}
},
onError: onErrorDialog('Dry Run', 'Failed to start dry run', {
capture: false,
log: ['Error starting dry run:'],
}),
})
}
+344
View File
@@ -0,0 +1,344 @@
import {
type Dispatch,
type SetStateAction,
startTransition,
useCallback,
useEffect,
useMemo,
useState,
} from 'react'
import type { FlagValue } from '../../../types/rclone'
export interface OptionGroupDef<K extends string = string> {
// Group identity: the jsonError step name and the state key. Parse order = defs order.
key: K
// Key looked up in a template's grouped options, when different from `key` (Move and Bisync
// load the template's `copy` group into their own group). Defaults to `key`.
templateKey?: string
// Seeded into the JSON string on mount and restored by resetJson. Omit for '{}'.
defaults?: Record<string, FlagValue>
}
export interface OptionGroupState {
options: Record<string, FlagValue>
jsonString: string
setJsonString: (value: string) => void
locked: boolean
setLocked: (value: boolean) => void
}
export interface RemoteOptionsGroupState {
// Last-valid parsed snapshot (the args source). Frozen whole while ANY tab is invalid.
options: Record<string, Record<string, FlagValue>>
// Raw per-remote JSON documents (remote name -> options JSON doc), possibly invalid mid-edit.
json: Record<string, string>
setJson: Dispatch<SetStateAction<Record<string, string>>>
// Called by RemoteOptionsSection with the current unique remote names; rebuilds the tab
// strings from the last-valid parsed doc when the remote COUNT changes (either direction),
// or unconditionally on `force` (the view's first call after mounting).
reconcile: (remoteNames: string[], force?: boolean) => void
locked: boolean
setLocked: (value: boolean) => void
}
/**
* Owns the option-group state of an operation page: per-group locked/JSON-string/parsed values,
* defaults seeding, template load and resets. The JSON string is the single source of truth
* parsed values are derived by the parse effect, and invalid JSON retains the last-good parsed
* values PAGE-WIDE (one try/catch: any invalid group freezes every group's parsed state) with
* jsonError reporting the first failing group in defs order exactly the semantics the pages
* hand-rolled.
*
* `withRemotes` adds the remotes group: raw per-remote JSON documents owned here and edited by
* RemoteOptionsSection tabs. Its parsing is SEPARATE from the groups' try/catch an invalid
* remote tab must never set jsonError or disable start; instead retention is ALL-OR-NOTHING
* across remotes (one invalid tab freezes the parsed snapshot for every remote).
*
* Group defs must be static they are captured on first render.
*/
export function useOptionGroups<K extends string>({
groups,
withRemotes = false,
}: {
groups: readonly OptionGroupDef<K>[]
withRemotes?: boolean
}) {
// Defs are static per page — freeze the first-render value so effects don't depend on an
// inline-array identity.
const [defs] = useState(groups)
const [jsonStrings, setJsonStrings] = useState<Record<K, string>>(
() => Object.fromEntries(defs.map((g) => [g.key, '{}'])) as Record<K, string>
)
const [parsed, setParsed] = useState<Record<K, Record<string, FlagValue>>>(
() =>
Object.fromEntries(defs.map((g) => [g.key, {}])) as Record<K, Record<string, FlagValue>>
)
const [locked, setLockedMap] = useState<Record<K, boolean>>(
() => Object.fromEntries(defs.map((g) => [g.key, false])) as Record<K, boolean>
)
// The remotes group replicates the old two-stage pipeline exactly:
// tab strings -> (all-or-nothing parse) -> remoteDocParsed -> (gated on group validity)
// -> remoteParsed (the args source).
// remoteDocParsed is the old "outer doc": the last successful full parse of the tab strings.
// Reset clears IT (not the strings), so tabs keep their text, submits carry no remote
// options, and the next tab edit re-parses everything back in — the old chain's behavior.
const [remoteOptionsJson, setRemoteOptionsJson] = useState<Record<string, string>>({})
const [remoteDocParsed, setRemoteDocParsed] = useState<
Record<string, Record<string, FlagValue>>
>({})
const [remoteParsed, setRemoteParsed] = useState<Record<string, Record<string, FlagValue>>>({})
const [remoteLocked, setRemoteLocked] = useState(false)
const [jsonError, setJsonError] = useState<K | null>(null)
// Bumped by a forced (mount-time) reconcile so the groups parse effect re-runs even when the
// rebuilt strings round-trip to a value-equal doc — replicating the old mount write-back
// side-channel that re-latched jsonError on a still-invalid group after a remount.
const [parseNonce, setParseNonce] = useState(0)
// Seed group defaults into the JSON strings on mount (the parse effect derives the values).
// defs is frozen on first render, so this still runs exactly once.
useEffect(() => {
startTransition(() => {
setJsonStrings((prev) => {
const next = { ...prev }
for (const g of defs) {
if (g.defaults) {
next[g.key] = JSON.stringify(g.defaults, null, 2)
}
}
return next
})
})
}, [defs])
// Parse effect: single try/catch over all groups so ANY invalid group freezes EVERY group's
// parsed values until the user fixes the JSON. remoteDocParsed is a dep on purpose — the old
// page effect re-parsed on outer-doc changes too, so a remote-tab edit while a group is
// invalid re-throws and RE-LATCHES jsonError (re-disabling START), and the remotes args
// source syncs to the doc only on a successful full parse.
// biome-ignore lint/correctness/useExhaustiveDependencies: parseNonce is a deliberate re-run trigger the body doesn't read
useEffect(() => {
let step: K = defs[0].key
try {
const nextParsed = {} as Record<K, Record<string, FlagValue>>
for (const g of defs) {
step = g.key
nextParsed[g.key] = JSON.parse(jsonStrings[g.key]) as Record<string, FlagValue>
}
startTransition(() => {
setParsed(nextParsed)
if (withRemotes) {
setRemoteParsed(remoteDocParsed)
}
setJsonError(null)
})
} catch (error) {
setJsonError(step)
console.error(`Error parsing ${step} options:`, error)
}
}, [defs, jsonStrings, remoteDocParsed, parseNonce, withRemotes])
// Remotes parse — deliberately OUTSIDE the groups' try/catch: an invalid remote tab must NOT
// set jsonError or disable start. Retention is ALL-OR-NOTHING across remotes: the loop
// returns on the first invalid entry, freezing the doc for EVERY remote until the user fixes
// the tab (a valid edit in another tab does not reach submit meanwhile). The value-equality
// bailout mirrors the old write-back's identical-string setState bailout.
useEffect(() => {
if (!withRemotes) {
return
}
const next: Record<string, Record<string, FlagValue>> = {}
for (const [remote, json] of Object.entries(remoteOptionsJson)) {
try {
next[remote] = JSON.parse(json) as Record<string, FlagValue>
} catch {
return
}
}
startTransition(() => {
setRemoteDocParsed((prev) =>
JSON.stringify(prev) === JSON.stringify(next) ? prev : next
)
})
}, [remoteOptionsJson, withRemotes])
// Stable per-group setters (defs are static). Returning `prev` unchanged on a same-value
// write preserves React's Object.is bailout the dedicated useState setters had — a no-op
// write must not re-run the parse effect.
const setters = useMemo(() => {
const map = {} as Record<
K,
{ setJsonString: (value: string) => void; setLocked: (value: boolean) => void }
>
for (const g of defs) {
map[g.key] = {
setJsonString: (value: string) =>
setJsonStrings((prev) =>
prev[g.key] === value ? prev : { ...prev, [g.key]: value }
),
setLocked: (value: boolean) =>
setLockedMap((prev) =>
prev[g.key] === value ? prev : { ...prev, [g.key]: value }
),
}
}
return map
}, [defs])
const groupStates = useMemo(() => {
const map = {} as Record<K, OptionGroupState>
for (const g of defs) {
map[g.key] = {
options: parsed[g.key],
jsonString: jsonStrings[g.key],
setJsonString: setters[g.key].setJsonString,
locked: locked[g.key],
setLocked: setters[g.key].setLocked,
}
}
return map
}, [defs, parsed, jsonStrings, locked, setters])
// Count-change semantics (mirrors the old length-equality init guard): when the remote COUNT
// changes in EITHER direction, rebuild ALL tabs' strings from the last-valid parsed doc —
// pruning deselected remotes, seeding new ones with '{}', and discarding mid-edit invalid
// text. Same-count changes deliberately do not rebuild — EXCEPT on `force`, which the view
// passes on its first call after (re)mounting: the old tab strings were child state destroyed
// on unmount, so a remount always rebuilt from the doc regardless of the count.
const reconcileRemotes = useCallback(
(remoteNames: string[], force = false) => {
startTransition(() => {
if (force) {
// A remount must re-run the groups parse once regardless of whether the
// rebuild changes anything (see parseNonce).
setParseNonce((n) => n + 1)
}
setRemoteOptionsJson((prev) => {
if (!force && Object.keys(prev).length === remoteNames.length) {
return prev
}
const next: Record<string, string> = {}
let changed = remoteNames.length !== Object.keys(prev).length
for (const remote of remoteNames) {
const lastValid = remoteDocParsed[remote]
next[remote] =
lastValid !== undefined ? JSON.stringify(lastValid, null, 2) : '{}'
if (next[remote] !== prev[remote]) {
changed = true
}
}
return changed ? next : prev
})
})
},
[remoteDocParsed]
)
const remotes: RemoteOptionsGroupState = useMemo(
() => ({
options: remoteParsed,
json: remoteOptionsJson,
setJson: setRemoteOptionsJson,
reconcile: reconcileRemotes,
locked: remoteLocked,
setLocked: setRemoteLocked,
}),
[remoteParsed, remoteOptionsJson, reconcileRemotes, remoteLocked]
)
// Template load writes the JSON-STRING state (the parse effect derives parsed values); merge
// spreads the incoming group over the current PARSED values, exactly as the pages did.
const applyTemplate = useCallback(
(groupedOptions: Record<string, unknown>, shouldMerge: boolean) => {
startTransition(() => {
setJsonStrings((prev) => {
let changed = false
const next = { ...prev }
for (const g of defs) {
const incoming = groupedOptions[g.templateKey ?? g.key] as
| Record<string, FlagValue>
| undefined
// Truthiness only (as the pages did): groupByCategory always returns
// objects, so replace mode rewrites every group — clearing uncovered
// ones to '{}'.
if (!incoming) {
continue
}
const serialized = JSON.stringify(
shouldMerge ? { ...parsed[g.key], ...incoming } : incoming,
null,
2
)
if (next[g.key] !== serialized) {
next[g.key] = serialized
changed = true
}
}
// Same-value bailout as the pages' individual setters had.
return changed ? next : prev
})
})
},
[defs, parsed]
)
// Spread of all parsed groups in defs order (remotes excluded) — the TemplatesDropdown
// getOptions source.
const getMergedOptions = useCallback((): Record<string, FlagValue> => {
const merged: Record<string, FlagValue> = {}
for (const g of defs) {
Object.assign(merged, parsed[g.key])
}
return merged
}, [defs, parsed])
// Restore every group's JSON string to its default (and remotes to '{}'), clearing jsonError.
const resetJson = useCallback(() => {
setJsonStrings((prev) => {
let changed = false
const next = { ...prev }
for (const g of defs) {
const value = g.defaults ? JSON.stringify(g.defaults, null, 2) : '{}'
if (next[g.key] !== value) {
next[g.key] = value
changed = true
}
}
return changed ? next : prev
})
if (withRemotes) {
// Reset clears only the DOC (and, via stage 2, the args source) and leaves the tab
// strings untouched — the old chain's exact behavior: after a reset the tabs still
// display their text, submits carry no remote options, and the next edit in any tab
// re-parses everything back in. Clearing the strings here would instead trigger a
// reconcile rebuild from the stale doc, undoing the reset.
setRemoteDocParsed({})
}
setJsonError(null)
}, [defs, withRemotes])
const resetLocks = useCallback(() => {
setLockedMap((prev) => {
if (defs.every((g) => prev[g.key] === false)) {
return prev
}
return Object.fromEntries(defs.map((g) => [g.key, false])) as Record<K, boolean>
})
setRemoteLocked(false)
}, [defs])
return {
jsonError,
setJsonError,
groups: groupStates,
remotes,
applyTemplate,
getMergedOptions,
resetJson,
resetLocks,
}
}
@@ -0,0 +1,68 @@
import { useMutation } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core'
import cronstrue from 'cronstrue'
import { onErrorDialog } from '../../../lib/errors'
import notify from '../../../lib/notify'
import { useHostStore } from '../../../store/host'
import type { ScheduledTask } from '../../../types/schedules'
/**
* The schedule mutation shared by the operation pages: page-specific validation (path checks,
* the Copy/Move multi-source license gate) cron validation native name prompt
* addScheduledTask with the page-built args. `buildArgs` must return the EXACT persisted args
* shape the page's start function takes main.ts replays these verbatim.
*/
export function useScheduleTask<O extends ScheduledTask['operation']>({
operation,
cronExpression,
buildArgs,
validate,
}: {
operation: O
cronExpression: string | null
buildArgs: () => Extract<ScheduledTask, { operation: O }>['args']
validate?: () => void
}) {
return useMutation({
mutationFn: async () => {
validate?.()
if (!cronExpression) {
throw new Error('Please enter a cron expression')
}
try {
cronstrue.toString(cronExpression)
} catch {
throw new Error('Invalid cron expression')
}
const name = await invoke<string | null>('prompt', {
title: 'Schedule Name',
message: 'Enter a name for this schedule',
default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`,
})
if (!name) {
throw new Error('Schedule name is required')
}
useHostStore.getState().addScheduledTask({
name,
operation,
cron: cronExpression,
args: buildArgs(),
})
},
onSuccess: async () => {
await notify({
title: 'Success',
body: 'New schedule has been created',
})
},
onError: onErrorDialog('Schedule', 'Failed to schedule task', {
capture: false,
log: ['Error scheduling task:'],
}),
})
}
+3 -3
View File
@@ -64,9 +64,9 @@ if (
// placed here to avoid circular dependency
usePersistedStore.subscribe(async (state, prevState) => {
if (state.currentHost?.id !== prevState.currentHost?.id && state.currentHost?.id) {
console.log('[Store] Host changed to', state.currentHost.id)
await initHostStore(state.currentHost.id).catch(console.error)
if (state.currentHostId !== prevState.currentHostId && state.currentHostId) {
console.log('[Store] Host changed to', state.currentHostId)
await initHostStore(state.currentHostId).catch(console.error)
await queryClient.cancelQueries()
clearClient()
queryClient.clear()
+254 -503
View File
@@ -1,51 +1,71 @@
import {
Accordion,
AccordionItem,
Avatar,
Button,
ButtonGroup,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
Switch,
Tooltip,
} from '@heroui/react'
import { Switch } from '@heroui/react'
import { useMutation } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core'
import { message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import cronstrue from 'cronstrue'
import { AnimatePresence, motion } from 'framer-motion'
import {
AlertOctagonIcon,
ClockIcon,
DiamondPercentIcon,
FilterIcon,
FoldersIcon,
PlayIcon,
ServerIcon,
WrenchIcon,
} from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
import { startTransition, useCallback, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks'
import notify from '../../lib/notify'
import { startBisync } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { openWindow } from '../../lib/window'
import { useHostStore } from '../../store/host'
import type { FlagValue } from '../../types/rclone'
import CommandInfoButton from '../components/CommandInfoButton'
import CommandsDropdown from '../components/CommandsDropdown'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection'
import { PathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection'
import TemplatesDropdown from '../components/TemplatesDropdown'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
} from '../components/operation/OptionsAccordion'
import { useOptionGroups } from '../components/operation/useOptionGroups'
import { useScheduleTask } from '../components/operation/useScheduleTask'
const HELP_CONTENT = `Performs bidirectional synchronization between two paths.
Bisync keeps both Path1 and Path2 in sync by propagating changes in both directions. On each run, it compares the current state to the previous run and detects New, Newer, Older, and Deleted files on each side, then propagates those changes to the other path.
Bisync retains the filesystem listings from the prior run. This history allows it to determine what has changed since the last sync. If something evil happens, bisync goes into a safe state to block damage by later runs you may need to run with resync to recover.
This is an advanced command use with care. Unlike Copy or Sync which have a clear "source of truth", Bisync must resolve conflicts when both sides have changed. When a file changes on both sides and the versions differ, bisync will rename both versions as conflicts (e.g., file.conflict1, file.conflict2) so nothing is lost. Make sure you understand the behavior before using on important data.
If you only need one-way synchronization (making destination match source), use the SYNC command instead.
Here's a quick guide to using the Bisync command:
1. SELECT PATHS
Use the path selectors at the top to choose Path1 and Path2. Both paths will be kept in sync with each other there is no "source" or "destination", changes flow both ways.
2. CONFIGURE OPTIONS (Optional)
Expand the accordion sections to customize your bisync operation. The Bisync section has important switches at the top:
resync Required for the first run, or to reset bisync after an error. This makes both paths contain a matching superset of all files by copying Path2 to Path1, then Path1 to Path2. Only use resync when starting fresh, after changing filter settings, or recovering from an error using it routinely would prevent deletions from syncing (deleted files would keep reappearing from the other side).
checkAccess Safety check that looks for matching RCLONE_TEST files on both paths before syncing. You must first create these files yourself in both paths. This prevents data loss if a path is temporarily unavailable or mounted incorrectly.
force Override safety checks like max-delete protection. Use with caution, as this bypasses safeguards designed to prevent accidental mass deletions.
createEmptySrcDirs Sync empty directories as well as files. Without this, only files are synced and empty directories are ignored.
removeEmptyDirs Remove directories that become empty after syncing. Not compatible with createEmptySrcDirs use one or the other.
ignoreListingChecksum Skip checksum retrieval when creating file listings, which can speed things up considerably on backends where hashes must be computed on the fly (like local). Note this only affects listing comparisons, not the actual sync operations.
resilient Allow bisync to retry on the next run after certain errors, instead of requiring a resync. Useful for running bisync as a scheduled background process. Combine with --recover and --max-lock for a robust "set-it-and-forget-it" setup.
noCleanup Don't delete temporary working files after the operation. Useful for debugging issues, but normally you should leave this off.
3. OTHER OPTIONS
Tap any chip on the right to add it to the JSON editor. Hover over chips to see what each option does.
Filters Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age).
Config Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes.
Remotes Override backend-specific settings for remotes involved in this operation.
4. START BISYNC
Once paths are selected, tap "START BISYNC" to begin. For your first run, make sure "resync" is enabled to establish the initial baseline. You can monitor progress on the Transfers page.`
export default function Bisync() {
const [searchParams] = useSearchParams()
@@ -58,166 +78,87 @@ export default function Bisync() {
searchParams.get('initialDestination') ? searchParams.get('initialDestination')! : undefined
)
const [jsonError, setJsonError] = useState<'bisync' | 'filter' | 'config' | 'remote' | null>(
null
)
const {
jsonError,
setJsonError,
groups: optionGroups,
remotes: remotesGroup,
applyTemplate,
getMergedOptions,
resetJson,
resetLocks,
} = useOptionGroups({
groups: [
{ key: 'bisync', templateKey: 'copy', defaults: RCLONE_CONFIG_DEFAULTS.copy },
{ key: 'filter' },
{ key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config },
],
withRemotes: true,
})
const bisyncGroup = optionGroups.bisync
const filterGroup = optionGroups.filter
const configGroup = optionGroups.config
const [bisyncOptionsLocked, setBisyncOptionsLocked] = useState(false)
const [bisyncOptions, setBisyncOptions] = useState<Record<string, FlagValue>>({})
const [bisyncOptionsJsonString, setBisyncOptionsJsonString] = useState<string>('{}')
const [outerBisyncOptions, setOuterBisyncOptions] = useState<Record<string, boolean>>({})
const [filterOptionsLocked, setFilterOptionsLocked] = useState(false)
const [filterOptions, setFilterOptions] = useState<Record<string, FlagValue>>({})
const [filterOptionsJsonString, setFilterOptionsJsonString] = useState<string>('{}')
const [configOptionsLocked, setConfigOptionsLocked] = useState(false)
const [configOptions, setConfigOptions] = useState<Record<string, FlagValue>>({})
const [configOptionsJsonString, setConfigOptionsJsonString] = useState<string>('{}')
const [remoteOptionsLocked, setRemoteOptionsLocked] = useState(false)
const [remoteOptions, setRemoteOptions] = useState<Record<string, Record<string, FlagValue>>>(
{}
)
const [remoteOptionsJsonString, setRemoteOptionsJsonString] = useState<string>('{}')
const [cronExpression, setCronExpression] = useState<string | null>(null)
const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest])
const buildStartArgs = () => ({
source: source!,
destination: dest!,
options: {
config: configGroup.options,
bisync: bisyncGroup.options,
filter: filterGroup.options,
remotes: remotesGroup.options,
outer: outerBisyncOptions,
},
})
// The persisted schedule args deliberately omit the outer bisync switches — do not merge
// this with buildStartArgs.
const buildScheduleArgs = () => ({
source: source!,
destination: dest!,
options: {
config: configGroup.options,
bisync: bisyncGroup.options,
filter: filterGroup.options,
remotes: remotesGroup.options,
},
})
const startBisyncMutation = useMutation({
mutationFn: async () => {
if (!source || !dest) {
throw new Error('Please select both a source and destination path')
}
return startBisync({
source: source,
destination: dest,
options: {
config: configOptions,
bisync: bisyncOptions,
filter: filterOptions,
remotes: remoteOptions,
outer: outerBisyncOptions,
},
})
return startBisync(buildStartArgs())
},
onSuccess: () => {
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
onError: async (error) => {
console.error('Error starting bisync:', error)
const errorMessage =
error instanceof Error ? error.message : 'Failed to start bisync operation'
await message(errorMessage, {
title: 'Bisync',
kind: 'error',
})
},
onError: onErrorDialog('Bisync', 'Failed to start bisync operation', {
capture: false,
log: ['Error starting bisync:'],
}),
})
const scheduleTaskMutation = useMutation({
mutationFn: async () => {
const scheduleTaskMutation = useScheduleTask({
operation: 'bisync',
cronExpression,
validate: () => {
if (!source || !dest) {
throw new Error('Please select both a source and destination path')
}
if (!cronExpression) {
throw new Error('Please enter a cron expression')
}
try {
cronstrue.toString(cronExpression)
} catch {
throw new Error('Invalid cron expression')
}
const name = await invoke<string | null>('prompt', {
title: 'Schedule Name',
message: 'Enter a name for this schedule',
default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`,
})
if (!name) {
throw new Error('Schedule name is required')
}
useHostStore.getState().addScheduledTask({
name,
operation: 'bisync',
cron: cronExpression,
args: {
source,
destination: dest,
options: {
config: configOptions,
bisync: bisyncOptions,
filter: filterOptions,
remotes: remoteOptions,
},
},
buildArgs: buildScheduleArgs,
})
},
onSuccess: async () => {
await notify({
title: 'Success',
body: 'New schedule has been created',
})
},
onError: async (error) => {
console.error('Error scheduling task:', error)
await message(error instanceof Error ? error.message : 'Failed to schedule task', {
title: 'Schedule',
kind: 'error',
})
},
})
useEffect(() => {
startTransition(() => {
setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2))
setBisyncOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.copy, null, 2))
})
}, [])
useEffect(() => {
let step: 'bisync' | 'filter' | 'config' | 'remote' = 'bisync'
try {
const parsedBisync = JSON.parse(bisyncOptionsJsonString) as Record<string, FlagValue>
step = 'filter'
const parsedFilter = JSON.parse(filterOptionsJsonString) as Record<string, FlagValue>
step = 'config'
const parsedConfig = JSON.parse(configOptionsJsonString) as Record<string, FlagValue>
step = 'remote'
const outerRemote = JSON.parse(remoteOptionsJsonString) as Record<string, string>
const parsedRemote: Record<string, Record<string, FlagValue>> = {}
for (const [key, val] of Object.entries(outerRemote)) {
parsedRemote[key] = JSON.parse(val) as Record<string, FlagValue>
}
startTransition(() => {
setBisyncOptions(parsedBisync)
setFilterOptions(parsedFilter)
setConfigOptions(parsedConfig)
setRemoteOptions(parsedRemote)
setJsonError(null)
})
} catch (error) {
setJsonError(step)
console.error(`Error parsing ${step} options:`, error)
}
}, [
bisyncOptionsJsonString,
filterOptionsJsonString,
configOptionsJsonString,
remoteOptionsJsonString,
])
const buttonText = useMemo(() => {
if (startBisyncMutation.isPending) return 'STARTING...'
@@ -236,39 +177,14 @@ export default function Bisync() {
return <PlayIcon className="w-5 h-5 fill-current" />
}, [startBisyncMutation.isPending, source, dest, jsonError])
return (
<div className="flex flex-col h-screen gap-10">
{/* Main Content */}
<OperationWindowContent>
{/* Paths Display */}
<PathFinder
sourcePath={source}
setSourcePath={setSource}
destPath={dest}
setDestPath={setDest}
/>
<Accordion
keepContentMounted={true}
dividerProps={{
className: 'opacity-50',
}}
>
<AccordionItem
key="bisync"
startContent={
<Avatar
className="bg-lime-500"
radius="lg"
fallback={
<DiamondPercentIcon className="text-success-foreground" />
}
/>
}
indicator={<DiamondPercentIcon />}
title="Bisync"
subtitle={getOptionsSubtitle(Object.keys(bisyncOptions).length)}
>
const accordionItems = useMemo<OptionsAccordionItemDef[]>(
() => [
{
key: 'bisync',
category: 'bisync',
subtitle: getOptionsSubtitle(Object.keys(bisyncGroup.options).length),
children: (
<>
<div className="flex flex-row flex-wrap gap-2 pb-5">
<Switch
isSelected={outerBisyncOptions?.resync}
@@ -376,327 +292,162 @@ export default function Bisync() {
</div>
<OptionsSection
globalOptions={globalFlags?.main || {}}
optionsJson={bisyncOptionsJsonString}
setOptionsJson={setBisyncOptionsJsonString}
optionsJson={bisyncGroup.jsonString}
setOptionsJson={bisyncGroup.setJsonString}
availableOptions={copyFlags || []}
isLocked={bisyncOptionsLocked}
setIsLocked={setBisyncOptionsLocked}
isLocked={bisyncGroup.locked}
setIsLocked={bisyncGroup.setLocked}
/>
</AccordionItem>
<AccordionItem
key="filters"
startContent={
<Avatar color="danger" radius="lg" fallback={<FilterIcon />} />
}
indicator={<FilterIcon />}
title="Filters"
subtitle={getOptionsSubtitle(Object.keys(filterOptions).length)}
>
</>
),
},
{
key: 'filters',
category: 'filters',
subtitle: getOptionsSubtitle(Object.keys(filterGroup.options).length),
children: (
<OptionsSection
globalOptions={globalFlags?.filter || {}}
optionsJson={filterOptionsJsonString}
setOptionsJson={setFilterOptionsJsonString}
optionsJson={filterGroup.jsonString}
setOptionsJson={filterGroup.setJsonString}
availableOptions={filterFlags || []}
isLocked={filterOptionsLocked}
setIsLocked={setFilterOptionsLocked}
isLocked={filterGroup.locked}
setIsLocked={filterGroup.setLocked}
/>
</AccordionItem>
<AccordionItem
key="cron"
startContent={
<Avatar color="warning" radius="lg" fallback={<ClockIcon />} />
}
indicator={<ClockIcon />}
title="Cron"
>
<CronEditor expression={cronExpression} onChange={setCronExpression} />
</AccordionItem>
<AccordionItem
key="config"
startContent={
<Avatar color="default" radius="lg" fallback={<WrenchIcon />} />
}
indicator={<WrenchIcon />}
title="Config"
subtitle={getOptionsSubtitle(Object.keys(configOptions).length)}
>
),
},
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
{
key: 'config',
category: 'config',
subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length),
children: (
<OptionsSection
globalOptions={globalFlags?.main || {}}
optionsJson={configOptionsJsonString}
setOptionsJson={setConfigOptionsJsonString}
optionsJson={configGroup.jsonString}
setOptionsJson={configGroup.setJsonString}
availableOptions={configFlags || []}
isLocked={configOptionsLocked}
setIsLocked={setConfigOptionsLocked}
isLocked={configGroup.locked}
setIsLocked={configGroup.setLocked}
/>
</AccordionItem>
{selectedRemotes.length > 0 ? (
<AccordionItem
key={'remotes'}
startContent={
<Avatar
className="bg-fuchsia-500"
radius="lg"
fallback={<ServerIcon />}
/>
}
indicator={<ServerIcon />}
title={'Remotes'}
subtitle={getOptionsSubtitle(
Object.values(remoteOptions).reduce(
),
},
...(selectedRemotes.length > 0
? [
{
key: 'remotes',
category: 'remotes' as const,
subtitle: getOptionsSubtitle(
Object.values(remotesGroup.options).reduce(
(acc, opts) => acc + Object.keys(opts).length,
0
)
)}
>
),
children: (
<RemoteOptionsSection
selectedRemotes={selectedRemotes}
remoteOptionsJsonString={remoteOptionsJsonString}
setRemoteOptionsJsonString={setRemoteOptionsJsonString}
setRemoteOptionsLocked={setRemoteOptionsLocked}
remoteOptionsLocked={remoteOptionsLocked}
remoteOptionsJson={remotesGroup.json}
setRemoteOptionsJson={remotesGroup.setJson}
reconcileRemotes={remotesGroup.reconcile}
setRemoteOptionsLocked={remotesGroup.setLocked}
remoteOptionsLocked={remotesGroup.locked}
/>
</AccordionItem>
) : null}
</Accordion>
),
},
]
: []),
],
[
bisyncGroup,
outerBisyncOptions,
globalFlags,
copyFlags,
filterGroup,
filterFlags,
cronExpression,
configGroup,
configFlags,
selectedRemotes,
remotesGroup,
]
)
const handleStart = useCallback(
() => startBisyncMutation.mutate(),
[startBisyncMutation.mutate]
)
const handleSchedule = useCallback(
() => scheduleTaskMutation.mutate(),
[scheduleTaskMutation.mutate]
)
const handleResetPaths = useCallback(() => {
startTransition(() => {
setSource(undefined)
setDest(undefined)
setJsonError(null)
startBisyncMutation.reset()
})
}, [setJsonError, startBisyncMutation.reset])
const handleResetOptions = useCallback(() => {
startTransition(() => {
resetJson()
setOuterBisyncOptions({})
startBisyncMutation.reset()
})
}, [resetJson, startBisyncMutation.reset])
const handleResetAll = useCallback(() => {
startTransition(() => {
resetJson()
resetLocks()
setOuterBisyncOptions({})
setSource(undefined)
setDest(undefined)
startBisyncMutation.reset()
})
}, [resetJson, resetLocks, startBisyncMutation.reset])
return (
<div className="flex flex-col h-screen gap-10">
{/* Main Content */}
<OperationWindowContent>
{/* Paths Display */}
<PathFinder
sourcePath={source}
setSourcePath={setSource}
destPath={dest}
setDestPath={setDest}
/>
<OptionsAccordion items={accordionItems} />
</OperationWindowContent>
<OperationWindowFooter>
<TemplatesDropdown
isDisabled={!!jsonError}
<OperationFooter
operation="bisync"
onSelect={(groupedOptions, shouldMerge) => {
startTransition(() => {
if (shouldMerge) {
if (groupedOptions.copy)
setBisyncOptionsJsonString(JSON.stringify({ ...bisyncOptions, ...groupedOptions.copy }, null, 2))
if (groupedOptions.filter)
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2))
if (groupedOptions.config)
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2))
} else {
if (groupedOptions.copy) setBisyncOptionsJsonString(JSON.stringify(groupedOptions.copy, null, 2))
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2))
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2))
}
})
}}
getOptions={() => ({
...bisyncOptions,
...filterOptions,
...configOptions,
})}
templatesDisabled={!!jsonError}
onTemplateSelect={applyTemplate}
getTemplateOptions={getMergedOptions}
startIsSuccess={startBisyncMutation.isSuccess}
startIsPending={startBisyncMutation.isPending}
onStart={handleStart}
onSchedule={handleSchedule}
startBlocked={!!jsonError || !source || !dest || source === dest}
buttonText={buttonText}
buttonIcon={buttonIcon}
newLabel="NEW BISYNC"
onResetPaths={handleResetPaths}
onResetOptions={handleResetOptions}
onResetAll={handleResetAll}
helpContent={HELP_CONTENT}
/>
<AnimatePresence mode="wait" initial={false}>
{startBisyncMutation.isSuccess ? (
<motion.div
key="started-buttons"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex flex-1 gap-2"
>
<Dropdown shadow={platform() === 'windows' ? 'none' : undefined}>
<DropdownTrigger>
<Button
fullWidth={true}
color="primary"
size="lg"
data-focus-visible="false"
>
NEW BISYNC
</Button>
</DropdownTrigger>
<DropdownMenu>
<DropdownItem
key="reset-paths"
onPress={() => {
startTransition(() => {
setSource(undefined)
setDest(undefined)
setJsonError(null)
startBisyncMutation.reset()
})
}}
>
Reset Paths
</DropdownItem>
<DropdownItem
key="reset-options"
onPress={() => {
startTransition(() => {
setBisyncOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.copy,
null,
2
)
)
setFilterOptionsJsonString('{}')
setConfigOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.config,
null,
2
)
)
setRemoteOptionsJsonString('{}')
setOuterBisyncOptions({})
setJsonError(null)
startBisyncMutation.reset()
})
}}
>
Reset Options
</DropdownItem>
<DropdownItem
key="reset-all"
onPress={() => {
startTransition(() => {
setBisyncOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.copy,
null,
2
)
)
setFilterOptionsJsonString('{}')
setConfigOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.config,
null,
2
)
)
setRemoteOptionsJsonString('{}')
setOuterBisyncOptions({})
setBisyncOptionsLocked(false)
setFilterOptionsLocked(false)
setConfigOptionsLocked(false)
setRemoteOptionsLocked(false)
setJsonError(null)
setSource(undefined)
setDest(undefined)
startBisyncMutation.reset()
})
}}
>
Reset All
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Button
fullWidth={true}
size="lg"
color="secondary"
onPress={async () => {
await openWindow({
name: 'Transfers',
url: '/transfers',
})
}}
data-focus-visible="false"
>
VIEW TRANSFERS
</Button>
</motion.div>
) : (
<motion.div
key="start-button"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex flex-1"
>
<Button
onPress={() => setTimeout(() => startBisyncMutation.mutate(), 100)}
size="lg"
fullWidth={true}
type="button"
color="primary"
isDisabled={
startBisyncMutation.isPending ||
!!jsonError ||
!source ||
!dest ||
source === dest
}
isLoading={startBisyncMutation.isPending}
endContent={buttonIcon}
className="max-w-2xl gap-2"
data-focus-visible="false"
>
{buttonText}
</Button>
</motion.div>
)}
</AnimatePresence>
<ButtonGroup variant="flat">
<Tooltip content={'Schedule task'} placement="top" size="lg" color="foreground">
<Button
size="lg"
type="button"
color="primary"
isIconOnly={true}
onPress={() => {
setTimeout(() => scheduleTaskMutation.mutate(), 100)
}}
>
<ClockIcon className="size-6" />
</Button>
</Tooltip>
<CommandInfoButton
content={`Performs bidirectional synchronization between two paths.
Bisync keeps both Path1 and Path2 in sync by propagating changes in both directions. On each run, it compares the current state to the previous run and detects New, Newer, Older, and Deleted files on each side, then propagates those changes to the other path.
Bisync retains the filesystem listings from the prior run. This history allows it to determine what has changed since the last sync. If something evil happens, bisync goes into a safe state to block damage by later runs you may need to run with resync to recover.
This is an advanced command use with care. Unlike Copy or Sync which have a clear "source of truth", Bisync must resolve conflicts when both sides have changed. When a file changes on both sides and the versions differ, bisync will rename both versions as conflicts (e.g., file.conflict1, file.conflict2) so nothing is lost. Make sure you understand the behavior before using on important data.
If you only need one-way synchronization (making destination match source), use the SYNC command instead.
Here's a quick guide to using the Bisync command:
1. SELECT PATHS
Use the path selectors at the top to choose Path1 and Path2. Both paths will be kept in sync with each other there is no "source" or "destination", changes flow both ways.
2. CONFIGURE OPTIONS (Optional)
Expand the accordion sections to customize your bisync operation. The Bisync section has important switches at the top:
resync Required for the first run, or to reset bisync after an error. This makes both paths contain a matching superset of all files by copying Path2 to Path1, then Path1 to Path2. Only use resync when starting fresh, after changing filter settings, or recovering from an error using it routinely would prevent deletions from syncing (deleted files would keep reappearing from the other side).
checkAccess Safety check that looks for matching RCLONE_TEST files on both paths before syncing. You must first create these files yourself in both paths. This prevents data loss if a path is temporarily unavailable or mounted incorrectly.
force Override safety checks like max-delete protection. Use with caution, as this bypasses safeguards designed to prevent accidental mass deletions.
createEmptySrcDirs Sync empty directories as well as files. Without this, only files are synced and empty directories are ignored.
removeEmptyDirs Remove directories that become empty after syncing. Not compatible with createEmptySrcDirs use one or the other.
ignoreListingChecksum Skip checksum retrieval when creating file listings, which can speed things up considerably on backends where hashes must be computed on the fly (like local). Note this only affects listing comparisons, not the actual sync operations.
resilient Allow bisync to retry on the next run after certain errors, instead of requiring a resync. Useful for running bisync as a scheduled background process. Combine with --recover and --max-lock for a robust "set-it-and-forget-it" setup.
noCleanup Don't delete temporary working files after the operation. Useful for debugging issues, but normally you should leave this off.
3. OTHER OPTIONS
Tap any chip on the right to add it to the JSON editor. Hover over chips to see what each option does.
Filters Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age).
Config Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes.
Remotes Override backend-specific settings for remotes involved in this operation.
4. START BISYNC
Once paths are selected, tap "START BISYNC" to begin. For your first run, make sure "resync" is enabled to establish the initial baseline. You can monitor progress on the Transfers page.`}
/>
<CommandsDropdown currentCommand="bisync" />
</ButtonGroup>
</OperationWindowFooter>
</div>
)
+17 -25
View File
@@ -19,7 +19,7 @@ import {
import { useMutation, useQuery } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core'
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
import { ask, message, save } from '@tauri-apps/plugin-dialog'
import { ask, save } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import { AnimatePresence, motion } from 'framer-motion'
import {
@@ -35,6 +35,7 @@ import {
} from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Group, Panel, Separator } from 'react-resizable-panels'
import { onErrorDialog, reportError } from '../../lib/errors'
import { getFsInfo } from '../../lib/format'
// import { Document, Page, pdfjs } from 'react-pdf'
import { formatBytes } from '../../lib/format.ts'
@@ -123,9 +124,10 @@ export default function Browser() {
const jobId = result?.jobid
if (jobId) handleJobStarted(jobId)
} catch (error) {
await message(error instanceof Error ? error.message : 'Download failed', {
await reportError(error, {
title: 'Error',
kind: 'error',
fallback: 'Download failed',
capture: false,
})
}
},
@@ -160,9 +162,10 @@ export default function Browser() {
leftPanelRef.current?.refresh()
rightPanelRef.current?.refresh()
} catch (error) {
await message(error instanceof Error ? error.message : 'Delete failed', {
await reportError(error, {
title: 'Error',
kind: 'error',
fallback: 'Delete failed',
capture: false,
})
}
}, [])
@@ -209,9 +212,10 @@ export default function Browser() {
leftPanelRef.current?.refresh()
rightPanelRef.current?.refresh()
} catch (error) {
await message(error instanceof Error ? error.message : 'Rename failed', {
await reportError(error, {
title: 'Error',
kind: 'error',
fallback: 'Rename failed',
capture: false,
})
}
}, [])
@@ -235,14 +239,12 @@ export default function Browser() {
})
}
} catch (error) {
await message(
error instanceof Error ? error.message : 'Failed to generate public link',
{
await reportError(error, {
title: 'Share Error',
kind: 'error',
fallback: 'Failed to generate public link',
okLabel: 'OK',
}
)
capture: false,
})
}
}, [])
@@ -616,12 +618,7 @@ function OperationDialog({
onComplete?.()
onClose()
},
onError: async (error) => {
await message(error instanceof Error ? error.message : 'Copy operation failed', {
title: 'Error',
kind: 'error',
})
},
onError: onErrorDialog('Error', 'Copy operation failed', { capture: false }),
})
const moveMutation = useMutation({
@@ -645,12 +642,7 @@ function OperationDialog({
onComplete?.()
onClose()
},
onError: async (error) => {
await message(error instanceof Error ? error.message : 'Move operation failed', {
title: 'Error',
kind: 'error',
})
},
onError: onErrorDialog('Error', 'Move operation failed', { capture: false }),
})
const handleConfirm = useCallback(() => {
+307 -614
View File
@@ -1,628 +1,28 @@
import {
Accordion,
AccordionItem,
Avatar,
Button,
ButtonGroup,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
Tooltip,
} from '@heroui/react'
import { useMutation } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import cronstrue from 'cronstrue'
import { AnimatePresence, motion } from 'framer-motion'
import {
AlertOctagonIcon,
ClockIcon,
CopyIcon,
EyeIcon,
FilterIcon,
FoldersIcon,
PlayIcon,
ServerIcon,
WrenchIcon,
} from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks'
import notify from '../../lib/notify'
import { startCopy, startDryRun } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { openWindow } from '../../lib/window'
import { useHostStore } from '../../store/host'
import { usePersistedStore } from '../../store/persisted'
import type { FlagValue } from '../../types/rclone'
import CommandInfoButton from '../components/CommandInfoButton'
import CommandsDropdown from '../components/CommandsDropdown'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection'
import { MultiPathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection'
import ShowMoreOptionsBanner from '../components/ShowMoreOptionsBanner'
import TemplatesDropdown from '../components/TemplatesDropdown'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
} from '../components/operation/OptionsAccordion'
import { useOperationDryRun } from '../components/operation/useOperationDryRun'
import { useOptionGroups } from '../components/operation/useOptionGroups'
import { useScheduleTask } from '../components/operation/useScheduleTask'
export default function Copy() {
const [searchParams] = useSearchParams()
const { globalFlags, filterFlags, configFlags, copyFlags } = useFlags()
const [sources, setSources] = useState<string[] | undefined>(
searchParams.get('initialSource') ? [searchParams.get('initialSource')!] : undefined
)
const [dest, setDest] = useState<string | undefined>(
searchParams.get('initialDest') ? searchParams.get('initialDest')! : undefined
)
const [jsonError, setJsonError] = useState<'copy' | 'filter' | 'config' | 'remote' | null>(null)
const [copyOptionsLocked, setCopyOptionsLocked] = useState(false)
const [copyOptions, setCopyOptions] = useState<Record<string, FlagValue>>({})
const [copyOptionsJsonString, setCopyOptionsJsonString] = useState<string>('{}')
const [filterOptionsLocked, setFilterOptionsLocked] = useState(false)
const [filterOptions, setFilterOptions] = useState<Record<string, FlagValue>>({})
const [filterOptionsJsonString, setFilterOptionsJsonString] = useState<string>('{}')
const [configOptionsLocked, setConfigOptionsLocked] = useState(false)
const [configOptions, setConfigOptions] = useState<Record<string, FlagValue>>({})
const [configOptionsJsonString, setConfigOptionsJsonString] = useState<string>('{}')
const [remoteOptionsLocked, setRemoteOptionsLocked] = useState(false)
const [remoteOptions, setRemoteOptions] = useState<Record<string, Record<string, FlagValue>>>(
{}
)
const [remoteOptionsJsonString, setRemoteOptionsJsonString] = useState<string>('{}')
const [cronExpression, setCronExpression] = useState<string | null>(null)
const selectedRemotes = useMemo(
() => [...(sources || []), dest].filter(Boolean),
[sources, dest]
)
const startCopyMutation = useMutation({
mutationFn: async () => {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
}
return startCopy({
sources,
destination: dest,
options: {
config: configOptions,
copy: copyOptions,
filter: filterOptions,
remotes: remoteOptions,
},
})
},
onSuccess: () => {
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
onError: async (error) => {
console.error('Error starting copy:', error)
await message(error instanceof Error ? error.message : 'Failed to start copy', {
title: 'Copy',
kind: 'error',
})
},
})
const scheduleTaskMutation = useMutation({
mutationFn: async () => {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
}
if (sources.length > 1 && !usePersistedStore.getState().licenseValid) {
throw new Error('You need a valid license to schedule multiple tasks at once')
}
if (!cronExpression) {
throw new Error('Please enter a cron expression')
}
try {
cronstrue.toString(cronExpression)
} catch {
throw new Error('Invalid cron expression')
}
const name = await invoke<string | null>('prompt', {
title: 'Schedule Name',
message: 'Enter a name for this schedule',
default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`,
})
if (!name) {
throw new Error('Schedule name is required')
}
useHostStore.getState().addScheduledTask({
name,
operation: 'copy',
cron: cronExpression,
args: {
sources,
destination: dest,
options: {
config: configOptions,
copy: copyOptions,
filter: filterOptions,
remotes: remoteOptions,
},
},
})
},
onSuccess: async () => {
await notify({
title: 'Success',
body: 'New schedule has been created',
})
},
onError: async (error) => {
console.error('Error scheduling task:', error)
await message(error instanceof Error ? error.message : 'Failed to schedule task', {
title: 'Schedule',
kind: 'error',
})
},
})
const dryRunMutation = useMutation({
mutationFn: async () => {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
}
return startDryRun(() =>
startCopy({
sources,
destination: dest,
options: {
config: { ...configOptions, dry_run: true },
copy: copyOptions,
filter: filterOptions,
remotes: remoteOptions,
},
})
)
},
onSuccess: async () => {
const result = await ask(
'Dry run started, you can check the results in the Transfers screen',
{
title: 'Preview (Dry Run)',
kind: 'info',
okLabel: 'Open Transfers',
cancelLabel: 'OK',
}
)
if (result) {
await openWindow({ name: 'Transfers', url: '/transfers' })
}
},
onError: async (error) => {
console.error('Error starting dry run:', error)
await message(error instanceof Error ? error.message : 'Failed to start dry run', {
title: 'Dry Run',
kind: 'error',
})
},
})
useEffect(() => {
startTransition(() => {
setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2))
setCopyOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.copy, null, 2))
})
}, [])
useEffect(() => {
let step: 'copy' | 'filter' | 'config' | 'remote' = 'copy'
try {
const parsedCopy = JSON.parse(copyOptionsJsonString) as Record<string, FlagValue>
step = 'filter'
const parsedFilter = JSON.parse(filterOptionsJsonString) as Record<string, FlagValue>
step = 'config'
const parsedConfig = JSON.parse(configOptionsJsonString) as Record<string, FlagValue>
step = 'remote'
const outerRemote = JSON.parse(remoteOptionsJsonString) as Record<string, string>
const parsedRemote: Record<string, Record<string, FlagValue>> = {}
for (const [key, val] of Object.entries(outerRemote)) {
parsedRemote[key] = JSON.parse(val) as Record<string, FlagValue>
}
startTransition(() => {
setCopyOptions(parsedCopy)
setFilterOptions(parsedFilter)
setConfigOptions(parsedConfig)
setRemoteOptions(parsedRemote)
setJsonError(null)
})
} catch (error) {
setJsonError(step)
console.error(`Error parsing ${step} options:`, error)
}
}, [
copyOptionsJsonString,
filterOptionsJsonString,
configOptionsJsonString,
remoteOptionsJsonString,
])
const buttonText = useMemo(() => {
if (startCopyMutation.isPending) return 'STARTING...'
if (!sources || sources.length === 0) return 'Please select a source path'
if (!dest) return 'Please select a destination path'
if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE COPY'
return 'START COPY'
}, [startCopyMutation.isPending, sources, dest, jsonError, cronExpression])
const buttonIcon = useMemo(() => {
if (startCopyMutation.isPending) return
if (!sources || sources.length === 0 || !dest || sources.some((s) => s === dest))
return <FoldersIcon className="w-5 h-5" />
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
return <PlayIcon className="w-5 h-5 fill-current" />
}, [startCopyMutation.isPending, sources, dest, jsonError])
useEffect(() => {
console.log('[Copy] remoteOptions', remoteOptions)
console.log('[Copy] remoteOptionsJsonString', remoteOptionsJsonString)
}, [remoteOptionsJsonString, remoteOptions])
return (
<div className="flex flex-col h-screen gap-10">
{/* Main Content */}
<OperationWindowContent>
{/* Paths Display */}
<MultiPathFinder
sourcePaths={sources}
setSourcePaths={setSources}
destPath={dest}
setDestPath={setDest}
/>
<div className="relative flex flex-col">
<Accordion
keepContentMounted={true}
dividerProps={{
className: 'opacity-50',
}}
>
<AccordionItem
key="copy"
startContent={
<Avatar color="primary" radius="lg" fallback={<CopyIcon />} />
}
indicator={<CopyIcon />}
title="Copy"
subtitle={getOptionsSubtitle(Object.keys(copyOptions).length)}
>
<OptionsSection
globalOptions={globalFlags?.main || {}}
optionsJson={copyOptionsJsonString}
setOptionsJson={setCopyOptionsJsonString}
availableOptions={copyFlags || []}
isLocked={copyOptionsLocked}
setIsLocked={setCopyOptionsLocked}
/>
</AccordionItem>
<AccordionItem
key="filters"
startContent={
<Avatar color="danger" radius="lg" fallback={<FilterIcon />} />
}
indicator={<FilterIcon />}
title="Filters"
subtitle={getOptionsSubtitle(Object.keys(filterOptions).length)}
>
<OptionsSection
globalOptions={globalFlags?.filter || {}}
optionsJson={filterOptionsJsonString}
setOptionsJson={setFilterOptionsJsonString}
availableOptions={filterFlags || []}
isLocked={filterOptionsLocked}
setIsLocked={setFilterOptionsLocked}
/>
</AccordionItem>
<AccordionItem
key="cron"
startContent={
<Avatar color="warning" radius="lg" fallback={<ClockIcon />} />
}
indicator={<ClockIcon />}
title="Cron"
>
<CronEditor expression={cronExpression} onChange={setCronExpression} />
</AccordionItem>
<AccordionItem
key="config"
startContent={
<Avatar color="default" radius="lg" fallback={<WrenchIcon />} />
}
indicator={<WrenchIcon />}
title="Config"
subtitle={getOptionsSubtitle(Object.keys(configOptions).length)}
>
<OptionsSection
globalOptions={globalFlags?.main || {}}
optionsJson={configOptionsJsonString}
setOptionsJson={setConfigOptionsJsonString}
availableOptions={configFlags || []}
isLocked={configOptionsLocked}
setIsLocked={setConfigOptionsLocked}
/>
</AccordionItem>
{selectedRemotes.length > 0 ? (
<AccordionItem
key={'remotes'}
startContent={
<Avatar
className="bg-fuchsia-500"
radius="lg"
fallback={<ServerIcon />}
/>
}
indicator={<ServerIcon />}
title={'Remotes'}
subtitle={getOptionsSubtitle(
Object.values(remoteOptions).reduce(
(acc, opts) => acc + Object.keys(opts).length,
0
)
)}
>
<RemoteOptionsSection
selectedRemotes={selectedRemotes}
remoteOptionsJsonString={remoteOptionsJsonString}
setRemoteOptionsJsonString={setRemoteOptionsJsonString}
setRemoteOptionsLocked={setRemoteOptionsLocked}
remoteOptionsLocked={remoteOptionsLocked}
/>
</AccordionItem>
) : null}
</Accordion>
<ShowMoreOptionsBanner />
</div>
</OperationWindowContent>
<OperationWindowFooter>
<TemplatesDropdown
isDisabled={!!jsonError}
operation="copy"
onSelect={(groupedOptions, shouldMerge) => {
startTransition(() => {
if (shouldMerge) {
if (groupedOptions.copy)
setCopyOptionsJsonString(JSON.stringify({ ...copyOptions, ...groupedOptions.copy }, null, 2))
if (groupedOptions.filter)
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2))
if (groupedOptions.config)
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2))
} else {
if (groupedOptions.copy) setCopyOptionsJsonString(JSON.stringify(groupedOptions.copy, null, 2))
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2))
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2))
}
})
}}
getOptions={() => ({
...copyOptions,
...filterOptions,
...configOptions,
})}
/>
<AnimatePresence mode="wait" initial={false}>
{startCopyMutation.isSuccess ? (
<motion.div
key="started-buttons"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex flex-1 gap-2"
>
<Dropdown shadow={platform() === 'windows' ? 'none' : undefined}>
<DropdownTrigger>
<Button
fullWidth={true}
size="lg"
color="primary"
data-focus-visible="false"
>
NEW COPY
</Button>
</DropdownTrigger>
<DropdownMenu>
<DropdownItem
key="reset-paths"
onPress={() => {
startTransition(() => {
setSources(undefined)
setDest(undefined)
setJsonError(null)
startCopyMutation.reset()
})
}}
>
Reset Paths
</DropdownItem>
<DropdownItem
key="reset-options"
onPress={() => {
startTransition(() => {
setCopyOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.copy,
null,
2
)
)
setFilterOptionsJsonString('{}')
setConfigOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.config,
null,
2
)
)
setRemoteOptionsJsonString('{}')
setCronExpression(null)
setJsonError(null)
startCopyMutation.reset()
})
}}
>
Reset Options
</DropdownItem>
<DropdownItem
key="reset-all"
onPress={() => {
startTransition(() => {
setCopyOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.copy,
null,
2
)
)
setFilterOptionsJsonString('{}')
setConfigOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.config,
null,
2
)
)
setRemoteOptionsJsonString('{}')
setCopyOptionsLocked(false)
setFilterOptionsLocked(false)
setConfigOptionsLocked(false)
setRemoteOptionsLocked(false)
setCronExpression(null)
setJsonError(null)
setSources(undefined)
setDest(undefined)
startCopyMutation.reset()
})
}}
>
Reset All
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Button
fullWidth={true}
size="lg"
color="secondary"
onPress={async () => {
await openWindow({
name: 'Transfers',
url: '/transfers',
})
}}
data-focus-visible="false"
>
VIEW TRANSFERS
</Button>
</motion.div>
) : (
<motion.div
key="start-button"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex flex-1"
>
<Button
onPress={() => setTimeout(() => startCopyMutation.mutate(), 100)}
size="lg"
fullWidth={true}
type="button"
color="primary"
isDisabled={
startCopyMutation.isPending ||
!!jsonError ||
!sources ||
sources.length === 0 ||
!dest ||
sources.some((s) => s === dest)
}
isLoading={startCopyMutation.isPending}
endContent={buttonIcon}
className="max-w-2xl gap-2"
data-focus-visible="false"
>
{buttonText}
</Button>
</motion.div>
)}
</AnimatePresence>
<ButtonGroup variant="flat">
<Tooltip
content="Preview (Dry Run)"
placement="top"
size="lg"
color="foreground"
>
<Button
size="lg"
type="button"
color="primary"
isIconOnly={true}
isLoading={dryRunMutation.isPending}
onPress={() => {
if (
dryRunMutation.isPending ||
!!jsonError ||
!sources ||
sources.length === 0 ||
!dest ||
sources.some((s) => s === dest)
) {
return
}
setTimeout(() => dryRunMutation.mutate(), 100)
}}
>
<EyeIcon className="size-6" />
</Button>
</Tooltip>
<Tooltip content="Schedule task" placement="top" size="lg" color="foreground">
<Button
size="lg"
type="button"
color="primary"
isIconOnly={true}
onPress={() => {
setTimeout(() => scheduleTaskMutation.mutate(), 100)
}}
>
<ClockIcon className="size-6" />
</Button>
</Tooltip>
<CommandInfoButton
content={`Copies the source(s) to the destination.
const HELP_CONTENT = `Copies the source(s) to the destination.
Does not transfer files that are identical on source and destination (testing by size and modification time or MD5SUM). Does not delete files from the destination. If the destination path doesn't exist, it will be created automatically.
@@ -650,10 +50,303 @@ Expand the accordion sections to customize your copy operation. Tap any chip on
Tap the folder icon in the bottom bar to load or save option presets. Templates let you quickly apply common configurations without manually setting each option.
4. START THE COPY
Once paths are selected, tap "START COPY" to begin. You can monitor progress on the Transfers page.`}
Once paths are selected, tap "START COPY" to begin. You can monitor progress on the Transfers page.`
export default function Copy() {
const [searchParams] = useSearchParams()
const { globalFlags, filterFlags, configFlags, copyFlags } = useFlags()
const [sources, setSources] = useState<string[] | undefined>(
searchParams.get('initialSource') ? [searchParams.get('initialSource')!] : undefined
)
const [dest, setDest] = useState<string | undefined>(
searchParams.get('initialDest') ? searchParams.get('initialDest')! : undefined
)
const {
jsonError,
setJsonError,
groups: optionGroups,
remotes: remotesGroup,
applyTemplate,
getMergedOptions,
resetJson,
resetLocks,
} = useOptionGroups({
groups: [
{ key: 'copy', defaults: RCLONE_CONFIG_DEFAULTS.copy },
{ key: 'filter' },
{ key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config },
],
withRemotes: true,
})
const copyGroup = optionGroups.copy
const filterGroup = optionGroups.filter
const configGroup = optionGroups.config
const [cronExpression, setCronExpression] = useState<string | null>(null)
const selectedRemotes = useMemo(
() => [...(sources || []), dest].filter(Boolean),
[sources, dest]
)
const buildArgs = () => ({
sources: sources!,
destination: dest!,
options: {
config: configGroup.options,
copy: copyGroup.options,
filter: filterGroup.options,
remotes: remotesGroup.options,
},
})
const startCopyMutation = useMutation({
mutationFn: async () => {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
}
return startCopy(buildArgs())
},
onSuccess: () => {
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
onError: onErrorDialog('Copy', 'Failed to start copy', {
capture: false,
log: ['Error starting copy:'],
}),
})
const scheduleTaskMutation = useScheduleTask({
operation: 'copy',
cronExpression,
validate: () => {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
}
if (sources.length > 1 && !usePersistedStore.getState().licenseValid) {
throw new Error('You need a valid license to schedule multiple tasks at once')
}
},
buildArgs,
})
const dryRunMutation = useOperationDryRun(async () => {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
}
return startDryRun(() =>
startCopy({
sources,
destination: dest,
options: {
config: { ...configGroup.options, dry_run: true },
copy: copyGroup.options,
filter: filterGroup.options,
remotes: remotesGroup.options,
},
})
)
})
const buttonText = useMemo(() => {
if (startCopyMutation.isPending) return 'STARTING...'
if (!sources || sources.length === 0) return 'Please select a source path'
if (!dest) return 'Please select a destination path'
if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE COPY'
return 'START COPY'
}, [startCopyMutation.isPending, sources, dest, jsonError, cronExpression])
const buttonIcon = useMemo(() => {
if (startCopyMutation.isPending) return
if (!sources || sources.length === 0 || !dest || sources.some((s) => s === dest))
return <FoldersIcon className="w-5 h-5" />
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
return <PlayIcon className="w-5 h-5 fill-current" />
}, [startCopyMutation.isPending, sources, dest, jsonError])
const accordionItems = useMemo<OptionsAccordionItemDef[]>(
() => [
{
key: 'copy',
category: 'copy',
subtitle: getOptionsSubtitle(Object.keys(copyGroup.options).length),
children: (
<OptionsSection
globalOptions={globalFlags?.main || {}}
optionsJson={copyGroup.jsonString}
setOptionsJson={copyGroup.setJsonString}
availableOptions={copyFlags || []}
isLocked={copyGroup.locked}
setIsLocked={copyGroup.setLocked}
/>
),
},
{
key: 'filters',
category: 'filters',
subtitle: getOptionsSubtitle(Object.keys(filterGroup.options).length),
children: (
<OptionsSection
globalOptions={globalFlags?.filter || {}}
optionsJson={filterGroup.jsonString}
setOptionsJson={filterGroup.setJsonString}
availableOptions={filterFlags || []}
isLocked={filterGroup.locked}
setIsLocked={filterGroup.setLocked}
/>
),
},
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
{
key: 'config',
category: 'config',
subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length),
children: (
<OptionsSection
globalOptions={globalFlags?.main || {}}
optionsJson={configGroup.jsonString}
setOptionsJson={configGroup.setJsonString}
availableOptions={configFlags || []}
isLocked={configGroup.locked}
setIsLocked={configGroup.setLocked}
/>
),
},
...(selectedRemotes.length > 0
? [
{
key: 'remotes',
category: 'remotes' as const,
subtitle: getOptionsSubtitle(
Object.values(remotesGroup.options).reduce(
(acc, opts) => acc + Object.keys(opts).length,
0
)
),
children: (
<RemoteOptionsSection
selectedRemotes={selectedRemotes}
remoteOptionsJson={remotesGroup.json}
setRemoteOptionsJson={remotesGroup.setJson}
reconcileRemotes={remotesGroup.reconcile}
setRemoteOptionsLocked={remotesGroup.setLocked}
remoteOptionsLocked={remotesGroup.locked}
/>
),
},
]
: []),
],
[
copyGroup,
filterGroup,
configGroup,
remotesGroup,
globalFlags,
copyFlags,
filterFlags,
configFlags,
cronExpression,
selectedRemotes,
]
)
const handleStart = useCallback(() => startCopyMutation.mutate(), [startCopyMutation.mutate])
const handleSchedule = useCallback(
() => scheduleTaskMutation.mutate(),
[scheduleTaskMutation.mutate]
)
const handleDryRun = useCallback(() => dryRunMutation.mutate(), [dryRunMutation.mutate])
const handleResetPaths = useCallback(() => {
startTransition(() => {
setSources(undefined)
setDest(undefined)
setJsonError(null)
startCopyMutation.reset()
})
}, [setJsonError, startCopyMutation.reset])
const handleResetOptions = useCallback(() => {
startTransition(() => {
resetJson()
setCronExpression(null)
startCopyMutation.reset()
})
}, [resetJson, startCopyMutation.reset])
const handleResetAll = useCallback(() => {
startTransition(() => {
resetJson()
resetLocks()
setCronExpression(null)
setSources(undefined)
setDest(undefined)
startCopyMutation.reset()
})
}, [resetJson, resetLocks, startCopyMutation.reset])
useEffect(() => {
console.log('[Copy] remoteOptions', remotesGroup.options)
console.log('[Copy] remoteOptionsJson', remotesGroup.json)
}, [remotesGroup.json, remotesGroup.options])
return (
<div className="flex flex-col h-screen gap-10">
{/* Main Content */}
<OperationWindowContent>
{/* Paths Display */}
<MultiPathFinder
sourcePaths={sources}
setSourcePaths={setSources}
destPath={dest}
setDestPath={setDest}
/>
<OptionsAccordion banner={true} items={accordionItems} />
</OperationWindowContent>
<OperationWindowFooter>
<OperationFooter
operation="copy"
templatesDisabled={!!jsonError}
onTemplateSelect={applyTemplate}
getTemplateOptions={getMergedOptions}
startIsSuccess={startCopyMutation.isSuccess}
startIsPending={startCopyMutation.isPending}
onStart={handleStart}
onSchedule={handleSchedule}
dryRunIsPending={dryRunMutation.isPending}
onDryRun={handleDryRun}
startBlocked={
!!jsonError ||
!sources ||
sources.length === 0 ||
!dest ||
sources.some((s) => s === dest)
}
buttonText={buttonText}
buttonIcon={buttonIcon}
newLabel="NEW COPY"
onResetPaths={handleResetPaths}
onResetOptions={handleResetOptions}
onResetAll={handleResetAll}
helpContent={HELP_CONTENT}
/>
<CommandsDropdown currentCommand="copy" />
</ButtonGroup>
</OperationWindowFooter>
</div>
)
+256 -498
View File
@@ -1,511 +1,31 @@
import {
Accordion,
AccordionItem,
Alert,
Avatar,
Button,
ButtonGroup,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
Tooltip,
} from '@heroui/react'
import * as Sentry from '@sentry/browser'
import { useMutation, useQuery } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import cronstrue from 'cronstrue'
import { AnimatePresence, motion } from 'framer-motion'
import {
AlertOctagonIcon,
ClockIcon,
EyeIcon,
FilterIcon,
FoldersIcon,
PlayIcon,
WrenchIcon,
} from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { Alert } from '@heroui/react'
import { useMutation } from '@tanstack/react-query'
import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
import { startTransition, useCallback, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags'
import { getRemoteName } from '../../lib/format'
import { useFlags } from '../../lib/hooks'
import { useFlags, useRemoteConfig } from '../../lib/hooks'
import notify from '../../lib/notify'
import { startDelete, startDryRun } from '../../lib/rclone/api'
import rclone from '../../lib/rclone/client'
import { RCLONE_CONFIG_DEFAULTS, SUPPORTS_PURGE } from '../../lib/rclone/constants'
import { openWindow } from '../../lib/window'
import { useHostStore } from '../../store/host'
import type { FlagValue } from '../../types/rclone'
import CommandInfoButton from '../components/CommandInfoButton'
import CommandsDropdown from '../components/CommandsDropdown'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection'
import { PathField } from '../components/PathFinder'
import TemplatesDropdown from '../components/TemplatesDropdown'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
} from '../components/operation/OptionsAccordion'
import { useOperationDryRun } from '../components/operation/useOperationDryRun'
import { useOptionGroups } from '../components/operation/useOptionGroups'
import { useScheduleTask } from '../components/operation/useScheduleTask'
export default function Delete() {
const [searchParams] = useSearchParams()
const { globalFlags, filterFlags, configFlags } = useFlags()
const PATH_ALLOWED_KEYS: ('LOCAL_FS' | 'FAVORITES' | 'REMOTES')[] = ['REMOTES', 'FAVORITES']
const [sourceFs, setSourceFs] = useState<string | undefined>(
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
)
const [cronExpression, setCronExpression] = useState<string | null>(null)
const [jsonError, setJsonError] = useState<'filter' | 'config' | null>(null)
const [filterOptionsLocked, setFilterOptionsLocked] = useState(false)
const [filterOptions, setFilterOptions] = useState<Record<string, FlagValue>>({})
const [filterOptionsJsonString, setFilterOptionsJsonString] = useState<string>('{}')
const [configOptionsLocked, setConfigOptionsLocked] = useState(false)
const [configOptions, setConfigOptions] = useState<Record<string, FlagValue>>({})
const [configOptionsJsonString, setConfigOptionsJsonString] = useState<string>('{}')
const sourceRemoteName = useMemo(() => getRemoteName(sourceFs), [sourceFs])
const sourceRemoteConfigQuery = useQuery({
queryKey: ['remote', sourceRemoteName, 'config'],
queryFn: async () => {
return await rclone('/config/get', {
params: {
query: {
name: sourceRemoteName!,
},
},
})
},
enabled: !!sourceRemoteName,
})
const supportsPurge = useMemo(
() =>
sourceRemoteConfigQuery.data
? SUPPORTS_PURGE.includes(sourceRemoteConfigQuery.data.type)
: false,
[sourceRemoteConfigQuery.data]
)
useEffect(() => {
startTransition(() => {
setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2))
})
}, [])
useEffect(() => {
let step: 'filter' | 'config' = 'filter'
try {
const parsedFilter = JSON.parse(filterOptionsJsonString) as Record<string, FlagValue>
step = 'config'
const parsedConfig = JSON.parse(configOptionsJsonString) as Record<string, FlagValue>
startTransition(() => {
setFilterOptions(parsedFilter)
setConfigOptions(parsedConfig)
setJsonError(null)
})
} catch (error) {
setJsonError(step)
console.error(`Error parsing ${step} options:`, error)
}
}, [filterOptionsJsonString, configOptionsJsonString])
const startDeleteMutation = useMutation({
mutationFn: async () => {
if (!sourceFs) {
throw new Error('Please select a source path to delete')
}
return startDelete({
sources: [sourceFs],
options: {
filter: filterOptions,
config: configOptions,
},
})
},
onSuccess: async () => {
await notify({
title: 'Success',
body: 'Delete task started',
})
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
onError: (error) => {
console.error('Error starting delete:', error)
Sentry.captureException(error)
},
})
const scheduleTaskMutation = useMutation({
mutationFn: async () => {
if (!sourceFs) {
throw new Error('Please select a source path to delete')
}
if (!cronExpression) {
throw new Error('Please enter a cron expression')
}
try {
cronstrue.toString(cronExpression)
} catch {
throw new Error('Invalid cron expression')
}
const name = await invoke<string | null>('prompt', {
title: 'Schedule Name',
message: 'Enter a name for this schedule',
default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`,
})
if (!name) {
throw new Error('Schedule name is required')
}
useHostStore.getState().addScheduledTask({
name,
operation: 'delete',
cron: cronExpression,
args: {
sources: [sourceFs],
options: {
filter: filterOptions,
config: configOptions,
},
},
})
},
onSuccess: async () => {
await notify({
title: 'Success',
body: 'New schedule has been created',
})
},
onError: async (error) => {
console.error('Error scheduling task:', error)
await message(error instanceof Error ? error.message : 'Failed to schedule task', {
title: 'Schedule',
kind: 'error',
})
},
})
const dryRunMutation = useMutation({
mutationFn: async () => {
if (!sourceFs) {
throw new Error('Please select a source path to delete')
}
return startDryRun(() =>
startDelete({
sources: [sourceFs],
options: {
filter: filterOptions,
config: { ...configOptions, dry_run: true },
},
})
)
},
onSuccess: async () => {
const result = await ask(
'Dry run started, you can check the results in the Transfers screen',
{
title: 'Preview (Dry Run)',
kind: 'info',
okLabel: 'Open Transfers',
cancelLabel: 'OK',
}
)
if (result) {
await openWindow({ name: 'Transfers', url: '/transfers' })
}
},
onError: async (error) => {
console.error('Error starting dry run:', error)
await message(error instanceof Error ? error.message : 'Failed to start dry run', {
title: 'Dry Run',
kind: 'error',
})
},
})
const buttonText = useMemo(() => {
if (startDeleteMutation.isPending) return 'STARTING...'
if (!sourceFs || sourceFs.length === 0) return 'Please select a source path'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE DELETE'
return 'START DELETE'
}, [startDeleteMutation.isPending, sourceFs, jsonError, cronExpression])
const buttonIcon = useMemo(() => {
if (startDeleteMutation.isPending) return
if (!sourceFs || sourceFs.length === 0) return <FoldersIcon className="w-5 h-5" />
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
return <PlayIcon className="w-5 h-5 fill-current" />
}, [startDeleteMutation.isPending, sourceFs, jsonError])
return (
<div className="flex flex-col h-screen gap-10">
{/* Main Content */}
<OperationWindowContent>
{/* Path Display */}
<PathField
path={sourceFs || ''}
setPath={setSourceFs}
label="Path"
placeholder="Enter a remote:/path to delete"
showPicker={true}
allowedKeys={['REMOTES', 'FAVORITES']}
showFiles={true}
/>
{supportsPurge && (
<Alert
color="primary"
title="LET ME SHARE A TIP"
variant="faded"
className="min-h-none h-fit max-h-fit"
>
If you're deleting a entire folder, "{sourceRemoteName}" supports Purge
which is more efficient!
</Alert>
)}
<Accordion
keepContentMounted={true}
dividerProps={{
className: 'opacity-50',
}}
>
<AccordionItem
key="filters"
startContent={
<Avatar color="danger" radius="lg" fallback={<FilterIcon />} />
}
indicator={<FilterIcon />}
title="Filters"
subtitle={getOptionsSubtitle(Object.keys(filterOptions).length)}
>
<OptionsSection
globalOptions={globalFlags?.filter ?? {}}
optionsJson={filterOptionsJsonString}
setOptionsJson={setFilterOptionsJsonString}
availableOptions={filterFlags || []}
isLocked={filterOptionsLocked}
setIsLocked={setFilterOptionsLocked}
/>
</AccordionItem>
<AccordionItem
key="config"
startContent={
<Avatar color="default" radius="lg" fallback={<WrenchIcon />} />
}
indicator={<WrenchIcon />}
title="Config"
subtitle={getOptionsSubtitle(Object.keys(configOptions).length)}
>
<OptionsSection
globalOptions={globalFlags?.main ?? {}}
optionsJson={configOptionsJsonString}
setOptionsJson={setConfigOptionsJsonString}
availableOptions={configFlags || []}
isLocked={configOptionsLocked}
setIsLocked={setConfigOptionsLocked}
/>
</AccordionItem>
<AccordionItem
key="cron"
startContent={
<Avatar color="warning" radius="lg" fallback={<ClockIcon />} />
}
indicator={<ClockIcon />}
title="Cron"
>
<CronEditor expression={cronExpression} onChange={setCronExpression} />
</AccordionItem>
</Accordion>
</OperationWindowContent>
<OperationWindowFooter>
<TemplatesDropdown
isDisabled={!!jsonError}
operation="delete"
onSelect={(groupedOptions, shouldMerge) => {
startTransition(() => {
if (shouldMerge) {
if (groupedOptions.filter)
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2))
if (groupedOptions.config)
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2))
} else {
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2))
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2))
}
})
}}
getOptions={() => ({
...filterOptions,
...configOptions,
})}
/>
<AnimatePresence mode="wait" initial={false}>
{startDeleteMutation.isSuccess ? (
<motion.div
key="started-buttons"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex flex-1 gap-2"
>
<Dropdown shadow={platform() === 'windows' ? 'none' : undefined}>
<DropdownTrigger>
<Button fullWidth={true} size="lg" data-focus-visible="false">
NEW DELETE
</Button>
</DropdownTrigger>
<DropdownMenu>
<DropdownItem
key="reset-paths"
onPress={() => {
startTransition(() => {
setSourceFs(undefined)
setJsonError(null)
startDeleteMutation.reset()
})
}}
>
Reset Path
</DropdownItem>
<DropdownItem
key="reset-options"
onPress={() => {
startTransition(() => {
setFilterOptionsJsonString('{}')
setConfigOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.config,
null,
2
)
)
setCronExpression(null)
setJsonError(null)
startDeleteMutation.reset()
})
}}
>
Reset Options
</DropdownItem>
<DropdownItem
key="reset-all"
onPress={() => {
startTransition(() => {
setFilterOptionsJsonString('{}')
setConfigOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.config,
null,
2
)
)
setFilterOptionsLocked(false)
setConfigOptionsLocked(false)
setCronExpression(null)
setJsonError(null)
setSourceFs(undefined)
startDeleteMutation.reset()
})
}}
>
Reset All
</DropdownItem>
</DropdownMenu>
</Dropdown>
</motion.div>
) : (
<motion.div
key="start-button"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex flex-1"
>
<Button
onPress={() => setTimeout(() => startDeleteMutation.mutate(), 100)}
size="lg"
fullWidth={true}
type="button"
color="primary"
isDisabled={
startDeleteMutation.isPending ||
!!jsonError ||
!sourceFs ||
sourceFs.length === 0
}
isLoading={startDeleteMutation.isPending}
endContent={buttonIcon}
className="max-w-2xl gap-2"
data-focus-visible="false"
>
{buttonText}
</Button>
</motion.div>
)}
</AnimatePresence>
<ButtonGroup variant="flat">
<Tooltip
content="Preview (Dry Run)"
placement="top"
size="lg"
color="foreground"
>
<Button
size="lg"
type="button"
color="primary"
isIconOnly={true}
isLoading={dryRunMutation.isPending}
onPress={() => {
if (
dryRunMutation.isPending ||
!!jsonError ||
!sourceFs ||
sourceFs.length === 0
) {
return
}
setTimeout(() => dryRunMutation.mutate(), 100)
}}
>
<EyeIcon className="size-6" />
</Button>
</Tooltip>
<Tooltip content="Schedule task" placement="top" size="lg" color="foreground">
<Button
size="lg"
type="button"
color="primary"
isIconOnly={true}
onPress={() => {
setTimeout(() => scheduleTaskMutation.mutate(), 100)
}}
>
<ClockIcon className="size-6" />
</Button>
</Tooltip>
<CommandInfoButton
content={`Removes files from the specified path.
const HELP_CONTENT = `Removes files from the specified path.
Unlike "Purge", Delete obeys include/exclude filters, so you can use it to selectively delete specific files. Delete only removes files but leaves the directory structure intact empty folders will remain after the files are deleted.
@@ -529,10 +49,248 @@ Expand the accordion sections to customize your delete operation. Tap any chip o
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.
4. START THE DELETE
Once a path is selected, tap "START DELETE" to begin. The operation will delete all files matching your filters (or all files if no filters are set). Empty directories will be left behind unless you use the rmdirs option.`}
Once a path is selected, tap "START DELETE" to begin. The operation will delete all files matching your filters (or all files if no filters are set). Empty directories will be left behind unless you use the rmdirs option.`
export default function Delete() {
const [searchParams] = useSearchParams()
const { globalFlags, filterFlags, configFlags } = useFlags()
const [sourceFs, setSourceFs] = useState<string | undefined>(
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
)
const [cronExpression, setCronExpression] = useState<string | null>(null)
const {
jsonError,
setJsonError,
groups: optionGroups,
applyTemplate,
getMergedOptions,
resetJson,
resetLocks,
} = useOptionGroups({
groups: [{ key: 'filter' }, { key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config }],
})
const filterGroup = optionGroups.filter
const configGroup = optionGroups.config
const sourceRemoteName = useMemo(() => getRemoteName(sourceFs), [sourceFs])
const sourceRemoteConfigQuery = useRemoteConfig(sourceRemoteName)
const supportsPurge = useMemo(
() =>
sourceRemoteConfigQuery.data
? SUPPORTS_PURGE.includes(sourceRemoteConfigQuery.data.type)
: false,
[sourceRemoteConfigQuery.data]
)
const buildArgs = () => ({
sources: [sourceFs!],
options: {
filter: filterGroup.options,
config: configGroup.options,
},
})
const startDeleteMutation = useMutation({
mutationFn: async () => {
if (!sourceFs) {
throw new Error('Please select a source path to delete')
}
return startDelete(buildArgs())
},
onSuccess: async () => {
await notify({
title: 'Success',
body: 'Delete task started',
})
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
onError: onErrorDialog('Delete', 'Failed to start delete', {
log: ['Error starting delete:'],
}),
})
const scheduleTaskMutation = useScheduleTask({
operation: 'delete',
cronExpression,
validate: () => {
if (!sourceFs) {
throw new Error('Please select a source path to delete')
}
},
buildArgs,
})
const dryRunMutation = useOperationDryRun(async () => {
if (!sourceFs) {
throw new Error('Please select a source path to delete')
}
return startDryRun(() =>
startDelete({
sources: [sourceFs],
options: {
filter: filterGroup.options,
config: { ...configGroup.options, dry_run: true },
},
})
)
})
const buttonText = useMemo(() => {
if (startDeleteMutation.isPending) return 'STARTING...'
if (!sourceFs || sourceFs.length === 0) return 'Please select a source path'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE DELETE'
return 'START DELETE'
}, [startDeleteMutation.isPending, sourceFs, jsonError, cronExpression])
const buttonIcon = useMemo(() => {
if (startDeleteMutation.isPending) return
if (!sourceFs || sourceFs.length === 0) return <FoldersIcon className="w-5 h-5" />
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
return <PlayIcon className="w-5 h-5 fill-current" />
}, [startDeleteMutation.isPending, sourceFs, jsonError])
const accordionItems = useMemo<OptionsAccordionItemDef[]>(
() => [
{
key: 'filters',
category: 'filters',
subtitle: getOptionsSubtitle(Object.keys(filterGroup.options).length),
children: (
<OptionsSection
globalOptions={globalFlags?.filter ?? {}}
optionsJson={filterGroup.jsonString}
setOptionsJson={filterGroup.setJsonString}
availableOptions={filterFlags || []}
isLocked={filterGroup.locked}
setIsLocked={filterGroup.setLocked}
/>
),
},
{
key: 'config',
category: 'config',
subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length),
children: (
<OptionsSection
globalOptions={globalFlags?.main ?? {}}
optionsJson={configGroup.jsonString}
setOptionsJson={configGroup.setJsonString}
availableOptions={configFlags || []}
isLocked={configGroup.locked}
setIsLocked={configGroup.setLocked}
/>
),
},
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
],
[filterGroup, configGroup, globalFlags, filterFlags, configFlags, cronExpression]
)
const handleStart = useCallback(
() => startDeleteMutation.mutate(),
[startDeleteMutation.mutate]
)
const handleSchedule = useCallback(
() => scheduleTaskMutation.mutate(),
[scheduleTaskMutation.mutate]
)
const handleDryRun = useCallback(() => dryRunMutation.mutate(), [dryRunMutation.mutate])
const handleResetPaths = useCallback(() => {
startTransition(() => {
setSourceFs(undefined)
setJsonError(null)
startDeleteMutation.reset()
})
}, [setJsonError, startDeleteMutation.reset])
const handleResetOptions = useCallback(() => {
startTransition(() => {
resetJson()
setCronExpression(null)
startDeleteMutation.reset()
})
}, [resetJson, startDeleteMutation.reset])
const handleResetAll = useCallback(() => {
startTransition(() => {
resetJson()
resetLocks()
setCronExpression(null)
setSourceFs(undefined)
startDeleteMutation.reset()
})
}, [resetJson, resetLocks, startDeleteMutation.reset])
return (
<div className="flex flex-col h-screen gap-10">
{/* Main Content */}
<OperationWindowContent>
{/* Path Display */}
<PathField
path={sourceFs || ''}
setPath={setSourceFs}
label="Path"
placeholder="Enter a remote:/path to delete"
showPicker={true}
allowedKeys={PATH_ALLOWED_KEYS}
showFiles={true}
/>
{supportsPurge && (
<Alert
color="primary"
title="LET ME SHARE A TIP"
variant="faded"
className="min-h-none h-fit max-h-fit"
>
If you're deleting a entire folder, "{sourceRemoteName}" supports Purge
which is more efficient!
</Alert>
)}
<OptionsAccordion items={accordionItems} />
</OperationWindowContent>
<OperationWindowFooter>
<OperationFooter
operation="delete"
templatesDisabled={!!jsonError}
onTemplateSelect={applyTemplate}
getTemplateOptions={getMergedOptions}
startIsSuccess={startDeleteMutation.isSuccess}
startIsPending={startDeleteMutation.isPending}
onStart={handleStart}
onSchedule={handleSchedule}
dryRunIsPending={dryRunMutation.isPending}
onDryRun={handleDryRun}
startBlocked={!!jsonError || !sourceFs || sourceFs.length === 0}
buttonText={buttonText}
buttonIcon={buttonIcon}
newLabel="NEW DELETE"
newButtonPrimary={false}
showViewTransfers={false}
resetPathsLabel="Reset Path"
onResetPaths={handleResetPaths}
onResetOptions={handleResetOptions}
onResetAll={handleResetAll}
helpContent={HELP_CONTENT}
/>
<CommandsDropdown currentCommand="delete" />
</ButtonGroup>
</OperationWindowFooter>
</div>
)
+5 -7
View File
@@ -8,6 +8,7 @@ import { AlertOctagonIcon, ClockIcon, DownloadIcon, FoldersIcon } from 'lucide-r
import pRetry from 'p-retry'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import notify from '../../lib/notify'
import rclone from '../../lib/rclone/client'
import CommandInfoButton from '../components/CommandInfoButton'
@@ -107,14 +108,11 @@ export default function Download() {
body: 'Download task started',
})
},
onError: async (error) => {
console.error('[Download] Failed to start download', error)
await message(error instanceof Error ? error.message : 'Failed to start download', {
title: 'Download Error',
kind: 'error',
onError: onErrorDialog('Download Error', 'Failed to start download', {
okLabel: 'OK',
})
},
capture: false,
log: ['[Download] Failed to start download'],
}),
})
const buttonText = useMemo(() => {
+49 -14
View File
@@ -28,6 +28,7 @@ import {
} from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { reportError } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks'
import { startMount } from '../../lib/rclone/api'
@@ -164,13 +165,11 @@ export default function Mount() {
}
console.log('[Mount] Mount plugin installed, but failed to start mount')
console.error('Failed to start mount:', error)
await message(
error instanceof Error ? error.message : 'Failed to start mount operation',
{
await reportError(error, {
title: 'Mount Error',
kind: 'error',
}
)
fallback: 'Failed to start mount operation',
capture: false,
})
},
})
@@ -308,18 +307,54 @@ export default function Mount() {
startTransition(() => {
if (shouldMerge) {
if (groupedOptions.mount)
setMountOptionsJsonString(JSON.stringify({ ...mountOptions, ...groupedOptions.mount }, null, 2))
setMountOptionsJsonString(
JSON.stringify(
{ ...mountOptions, ...groupedOptions.mount },
null,
2
)
)
if (groupedOptions.vfs)
setVfsOptionsJsonString(JSON.stringify({ ...vfsOptions, ...groupedOptions.vfs }, null, 2))
setVfsOptionsJsonString(
JSON.stringify(
{ ...vfsOptions, ...groupedOptions.vfs },
null,
2
)
)
if (groupedOptions.filter)
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2))
setFilterOptionsJsonString(
JSON.stringify(
{ ...filterOptions, ...groupedOptions.filter },
null,
2
)
)
if (groupedOptions.config)
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2))
setConfigOptionsJsonString(
JSON.stringify(
{ ...configOptions, ...groupedOptions.config },
null,
2
)
)
} else {
if (groupedOptions.mount) setMountOptionsJsonString(JSON.stringify(groupedOptions.mount, null, 2))
if (groupedOptions.vfs) setVfsOptionsJsonString(JSON.stringify(groupedOptions.vfs, null, 2))
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2))
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2))
if (groupedOptions.mount)
setMountOptionsJsonString(
JSON.stringify(groupedOptions.mount, null, 2)
)
if (groupedOptions.vfs)
setVfsOptionsJsonString(
JSON.stringify(groupedOptions.vfs, null, 2)
)
if (groupedOptions.filter)
setFilterOptionsJsonString(
JSON.stringify(groupedOptions.filter, null, 2)
)
if (groupedOptions.config)
setConfigOptionsJsonString(
JSON.stringify(groupedOptions.config, null, 2)
)
}
})
}}
+302 -636
View File
@@ -1,650 +1,28 @@
import {
Accordion,
AccordionItem,
Avatar,
Button,
ButtonGroup,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
Tooltip,
} from '@heroui/react'
import { useMutation } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import cronstrue from 'cronstrue'
import { AnimatePresence, motion } from 'framer-motion'
import {
AlertOctagonIcon,
ClockIcon,
EyeIcon,
FilterIcon,
FoldersIcon,
MoveIcon,
PlayIcon,
ServerIcon,
WrenchIcon,
} from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
import { startTransition, useCallback, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks'
import notify from '../../lib/notify'
import { startDryRun, startMove } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { openWindow } from '../../lib/window'
import { useHostStore } from '../../store/host'
import { usePersistedStore } from '../../store/persisted'
import type { FlagValue } from '../../types/rclone'
import CommandInfoButton from '../components/CommandInfoButton'
import CommandsDropdown from '../components/CommandsDropdown'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection'
import { MultiPathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection'
import ShowMoreOptionsBanner from '../components/ShowMoreOptionsBanner'
import TemplatesDropdown from '../components/TemplatesDropdown'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
} from '../components/operation/OptionsAccordion'
import { useOperationDryRun } from '../components/operation/useOperationDryRun'
import { useOptionGroups } from '../components/operation/useOptionGroups'
import { useScheduleTask } from '../components/operation/useScheduleTask'
/*
if (cronExpression) {
if (sources.length > 1) {
throw new Error(
'Cron is not supported for multiple sources, please use a single source'
)
}
try {
cronstrue.toString(cronExpression)
} catch {
throw new Error('Invalid cron expression')
}
useHostStore.getState().addScheduledTask({
type: 'move',
cron: cronExpression,
args: {
srcFs: sources[0],
dstFs: dest,
createEmptySrcDirs,
deleteEmptyDstDirs,
_config: mergedConfig,
_filter: filterOptions,
},
})
}
*/
export default function Move() {
const [searchParams] = useSearchParams()
const { globalFlags, filterFlags, configFlags, copyFlags } = useFlags()
const [sources, setSources] = useState<string[] | undefined>(
searchParams.get('initialSource') ? [searchParams.get('initialSource')!] : undefined
)
const [dest, setDest] = useState<string | undefined>(
searchParams.get('initialDest') ? searchParams.get('initialDest')! : undefined
)
const [jsonError, setJsonError] = useState<'move' | 'filter' | 'config' | 'remote' | null>(null)
const [moveOptionsLocked, setMoveOptionsLocked] = useState(false)
const [moveOptions, setMoveOptions] = useState<Record<string, FlagValue>>({})
const [moveOptionsJsonString, setMoveOptionsJsonString] = useState<string>('{}')
const [filterOptionsLocked, setFilterOptionsLocked] = useState(false)
const [filterOptions, setFilterOptions] = useState<Record<string, FlagValue>>({})
const [filterOptionsJsonString, setFilterOptionsJsonString] = useState<string>('{}')
const [configOptionsLocked, setConfigOptionsLocked] = useState(false)
const [configOptions, setConfigOptions] = useState<Record<string, FlagValue>>({})
const [configOptionsJsonString, setConfigOptionsJsonString] = useState<string>('{}')
const [remoteOptionsLocked, setRemoteOptionsLocked] = useState(false)
const [remoteOptions, setRemoteOptions] = useState<Record<string, Record<string, FlagValue>>>(
{}
)
const [remoteOptionsJsonString, setRemoteOptionsJsonString] = useState<string>('{}')
const [cronExpression, setCronExpression] = useState<string | null>(null)
const selectedRemotes = useMemo(
() => [...(sources || []), dest].filter(Boolean),
[sources, dest]
)
const startMoveMutation = useMutation({
mutationFn: async () => {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
}
return startMove({
sources,
destination: dest,
options: {
config: configOptions,
move: moveOptions,
filter: filterOptions,
remotes: remoteOptions,
},
})
},
onSuccess: () => {
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
onError: async (error) => {
console.error('Error starting move:', error)
await message(error instanceof Error ? error.message : 'Failed to start move', {
title: 'Move',
kind: 'error',
})
},
})
const scheduleTaskMutation = useMutation({
mutationFn: async () => {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
}
if (sources.length > 1 && !usePersistedStore.getState().licenseValid) {
throw new Error('You need a valid license to schedule multiple tasks at once')
}
if (!cronExpression) {
throw new Error('Please enter a cron expression')
}
try {
cronstrue.toString(cronExpression)
} catch {
throw new Error('Invalid cron expression')
}
const name = await invoke<string | null>('prompt', {
title: 'Schedule Name',
message: 'Enter a name for this schedule',
default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`,
})
if (!name) {
throw new Error('Schedule name is required')
}
useHostStore.getState().addScheduledTask({
name,
operation: 'move',
cron: cronExpression,
args: {
sources,
destination: dest,
options: {
config: configOptions,
move: moveOptions,
filter: filterOptions,
remotes: remoteOptions,
},
},
})
},
onSuccess: async () => {
await notify({
title: 'Success',
body: 'New schedule has been created',
})
},
onError: async (error) => {
console.error('Error scheduling task:', error)
await message(error instanceof Error ? error.message : 'Failed to schedule task', {
title: 'Schedule',
kind: 'error',
})
},
})
const dryRunMutation = useMutation({
mutationFn: async () => {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
}
return startDryRun(() =>
startMove({
sources,
destination: dest,
options: {
config: { ...configOptions, dry_run: true },
move: moveOptions,
filter: filterOptions,
remotes: remoteOptions,
},
})
)
},
onSuccess: async () => {
const result = await ask(
'Dry run started, you can check the results in the Transfers screen',
{
title: 'Preview (Dry Run)',
kind: 'info',
okLabel: 'Open Transfers',
cancelLabel: 'OK',
}
)
if (result) {
await openWindow({ name: 'Transfers', url: '/transfers' })
}
},
onError: async (error) => {
console.error('Error starting dry run:', error)
await message(error instanceof Error ? error.message : 'Failed to start dry run', {
title: 'Dry Run',
kind: 'error',
})
},
})
useEffect(() => {
startTransition(() => {
setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2))
setMoveOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.copy, null, 2))
})
}, [])
useEffect(() => {
let step: 'move' | 'filter' | 'config' | 'remote' = 'move'
try {
const parsedMove = JSON.parse(moveOptionsJsonString) as Record<string, FlagValue>
step = 'filter'
const parsedFilter = JSON.parse(filterOptionsJsonString) as Record<string, FlagValue>
step = 'config'
const parsedConfig = JSON.parse(configOptionsJsonString) as Record<string, FlagValue>
step = 'remote'
const outerRemote = JSON.parse(remoteOptionsJsonString) as Record<string, string>
const parsedRemote: Record<string, Record<string, FlagValue>> = {}
for (const [key, val] of Object.entries(outerRemote)) {
parsedRemote[key] = JSON.parse(val) as Record<string, FlagValue>
}
startTransition(() => {
setMoveOptions(parsedMove)
setFilterOptions(parsedFilter)
setConfigOptions(parsedConfig)
setRemoteOptions(parsedRemote)
setJsonError(null)
})
} catch (error) {
setJsonError(step)
console.error(`Error parsing ${step} options:`, error)
}
}, [
moveOptionsJsonString,
filterOptionsJsonString,
configOptionsJsonString,
remoteOptionsJsonString,
])
const buttonText = useMemo(() => {
if (startMoveMutation.isPending) return 'STARTING...'
if (!sources || sources.length === 0) return 'Please select a source path'
if (!dest) return 'Please select a destination path'
if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE MOVE'
return 'START MOVE'
}, [startMoveMutation.isPending, sources, dest, jsonError, cronExpression])
const buttonIcon = useMemo(() => {
if (startMoveMutation.isPending) return
if (!sources || sources.length === 0 || !dest || sources.some((s) => s === dest))
return <FoldersIcon className="w-5 h-5" />
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
return <PlayIcon className="w-5 h-5 fill-current" />
}, [startMoveMutation.isPending, sources, dest, jsonError])
return (
<div className="flex flex-col h-screen gap-10">
{/* Main Content */}
<OperationWindowContent>
{/* Paths Display */}
<MultiPathFinder
sourcePaths={sources}
setSourcePaths={setSources}
destPath={dest}
setDestPath={setDest}
/>
<div className="relative flex flex-col">
<Accordion
keepContentMounted={true}
dividerProps={{
className: 'opacity-50',
}}
>
<AccordionItem
key="move"
startContent={
<Avatar color="primary" radius="lg" fallback={<MoveIcon />} />
}
indicator={<MoveIcon />}
title="Move"
subtitle={getOptionsSubtitle(Object.keys(moveOptions).length)}
>
<OptionsSection
globalOptions={globalFlags?.main || {}}
optionsJson={moveOptionsJsonString}
setOptionsJson={setMoveOptionsJsonString}
availableOptions={copyFlags || []}
isLocked={moveOptionsLocked}
setIsLocked={setMoveOptionsLocked}
/>
</AccordionItem>
<AccordionItem
key="filters"
startContent={
<Avatar color="danger" radius="lg" fallback={<FilterIcon />} />
}
indicator={<FilterIcon />}
title="Filters"
subtitle={getOptionsSubtitle(Object.keys(filterOptions).length)}
>
<OptionsSection
globalOptions={globalFlags?.filter || {}}
optionsJson={filterOptionsJsonString}
setOptionsJson={setFilterOptionsJsonString}
availableOptions={filterFlags || []}
isLocked={filterOptionsLocked}
setIsLocked={setFilterOptionsLocked}
/>
</AccordionItem>
<AccordionItem
key="cron"
startContent={
<Avatar color="warning" radius="lg" fallback={<ClockIcon />} />
}
indicator={<ClockIcon />}
title="Cron"
>
<CronEditor expression={cronExpression} onChange={setCronExpression} />
</AccordionItem>
<AccordionItem
key="config"
startContent={
<Avatar color="default" radius="lg" fallback={<WrenchIcon />} />
}
indicator={<WrenchIcon />}
title="Config"
subtitle={getOptionsSubtitle(Object.keys(configOptions).length)}
>
<OptionsSection
globalOptions={globalFlags?.main || {}}
optionsJson={configOptionsJsonString}
setOptionsJson={setConfigOptionsJsonString}
availableOptions={configFlags || []}
isLocked={configOptionsLocked}
setIsLocked={setConfigOptionsLocked}
/>
</AccordionItem>
{selectedRemotes.length > 0 ? (
<AccordionItem
key={'remotes'}
startContent={
<Avatar
className="bg-fuchsia-500"
radius="lg"
fallback={<ServerIcon />}
/>
}
indicator={<ServerIcon />}
title={'Remotes'}
subtitle={getOptionsSubtitle(
Object.values(remoteOptions).reduce(
(acc, opts) => acc + Object.keys(opts).length,
0
)
)}
>
<RemoteOptionsSection
selectedRemotes={selectedRemotes}
remoteOptionsJsonString={remoteOptionsJsonString}
setRemoteOptionsJsonString={setRemoteOptionsJsonString}
setRemoteOptionsLocked={setRemoteOptionsLocked}
remoteOptionsLocked={remoteOptionsLocked}
/>
</AccordionItem>
) : null}
</Accordion>
<ShowMoreOptionsBanner />
</div>
</OperationWindowContent>
<OperationWindowFooter>
<TemplatesDropdown
isDisabled={!!jsonError}
operation="move"
onSelect={(groupedOptions, shouldMerge) => {
startTransition(() => {
if (shouldMerge) {
if (groupedOptions.copy)
setMoveOptionsJsonString(JSON.stringify({ ...moveOptions, ...groupedOptions.copy }, null, 2))
if (groupedOptions.filter)
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2))
if (groupedOptions.config)
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2))
} else {
if (groupedOptions.copy) setMoveOptionsJsonString(JSON.stringify(groupedOptions.copy, null, 2))
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2))
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2))
}
})
}}
getOptions={() => ({
...moveOptions,
...filterOptions,
...configOptions,
})}
/>
<AnimatePresence mode="wait" initial={false}>
{startMoveMutation.isSuccess ? (
<motion.div
key="started-buttons"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex flex-1 gap-2"
>
<Dropdown shadow={platform() === 'windows' ? 'none' : undefined}>
<DropdownTrigger>
<Button
fullWidth={true}
color="primary"
size="lg"
data-focus-visible="false"
>
NEW MOVE
</Button>
</DropdownTrigger>
<DropdownMenu>
<DropdownItem
key="reset-paths"
onPress={() => {
startTransition(() => {
setSources(undefined)
setDest(undefined)
setJsonError(null)
startMoveMutation.reset()
})
}}
>
Reset Paths
</DropdownItem>
<DropdownItem
key="reset-options"
onPress={() => {
startTransition(() => {
setMoveOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.copy,
null,
2
)
)
setFilterOptionsJsonString('{}')
setConfigOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.config,
null,
2
)
)
setRemoteOptionsJsonString('{}')
setCronExpression(null)
setJsonError(null)
startMoveMutation.reset()
})
}}
>
Reset Options
</DropdownItem>
<DropdownItem
key="reset-all"
onPress={() => {
startTransition(() => {
setMoveOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.copy,
null,
2
)
)
setFilterOptionsJsonString('{}')
setConfigOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.config,
null,
2
)
)
setRemoteOptionsJsonString('{}')
setMoveOptionsLocked(false)
setFilterOptionsLocked(false)
setConfigOptionsLocked(false)
setRemoteOptionsLocked(false)
setCronExpression(null)
setJsonError(null)
setSources(undefined)
setDest(undefined)
startMoveMutation.reset()
})
}}
>
Reset All
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Button
fullWidth={true}
size="lg"
color="secondary"
onPress={async () => {
await openWindow({
name: 'Transfers',
url: '/transfers',
})
}}
data-focus-visible="false"
>
VIEW TRANSFERS
</Button>
</motion.div>
) : (
<motion.div
key="start-button"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex flex-1"
>
<Button
onPress={() => setTimeout(() => startMoveMutation.mutate(), 100)}
size="lg"
fullWidth={true}
type="button"
color="primary"
isDisabled={
startMoveMutation.isPending ||
!!jsonError ||
!sources ||
sources.length === 0 ||
!dest ||
sources.some((s) => s === dest)
}
isLoading={startMoveMutation.isPending}
endContent={buttonIcon}
className="max-w-2xl gap-2"
data-focus-visible="false"
>
{buttonText}
</Button>
</motion.div>
)}
</AnimatePresence>
<ButtonGroup variant="flat">
<Tooltip
content="Preview (Dry Run)"
placement="top"
size="lg"
color="foreground"
>
<Button
size="lg"
type="button"
color="primary"
isIconOnly={true}
isLoading={dryRunMutation.isPending}
onPress={() => {
if (
dryRunMutation.isPending ||
!!jsonError ||
!sources ||
sources.length === 0 ||
!dest ||
sources.some((s) => s === dest)
) {
return
}
setTimeout(() => dryRunMutation.mutate(), 100)
}}
>
<EyeIcon className="size-6" />
</Button>
</Tooltip>
<Tooltip content="Schedule task" placement="top" size="lg" color="foreground">
<Button
size="lg"
type="button"
color="primary"
isIconOnly={true}
onPress={() => {
setTimeout(() => scheduleTaskMutation.mutate(), 100)
}}
>
<ClockIcon className="size-6" />
</Button>
</Tooltip>
<CommandInfoButton
content={`Moves the source(s) to the destination directory.
const HELP_CONTENT = `Moves the source(s) to the destination directory.
Unlike Copy, Move deletes files from the source after they have been transferred to the destination. After a successful move, the source path will no longer exist.
@@ -676,10 +54,298 @@ Expand the accordion sections to customize your move operation. Tap any chip on
Tap the folder icon in the bottom bar to load or save option presets. Templates let you quickly apply common configurations without manually setting each option.
4. START THE MOVE
Once paths are selected, tap "START MOVE" to begin. You can monitor progress on the Transfers page.`}
Once paths are selected, tap "START MOVE" to begin. You can monitor progress on the Transfers page.`
export default function Move() {
const [searchParams] = useSearchParams()
const { globalFlags, filterFlags, configFlags, copyFlags } = useFlags()
const [sources, setSources] = useState<string[] | undefined>(
searchParams.get('initialSource') ? [searchParams.get('initialSource')!] : undefined
)
const [dest, setDest] = useState<string | undefined>(
searchParams.get('initialDest') ? searchParams.get('initialDest')! : undefined
)
const {
jsonError,
setJsonError,
groups: optionGroups,
remotes: remotesGroup,
applyTemplate,
getMergedOptions,
resetJson,
resetLocks,
} = useOptionGroups({
groups: [
{ key: 'move', templateKey: 'copy', defaults: RCLONE_CONFIG_DEFAULTS.copy },
{ key: 'filter' },
{ key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config },
],
withRemotes: true,
})
const moveGroup = optionGroups.move
const filterGroup = optionGroups.filter
const configGroup = optionGroups.config
const [cronExpression, setCronExpression] = useState<string | null>(null)
const selectedRemotes = useMemo(
() => [...(sources || []), dest].filter(Boolean),
[sources, dest]
)
const buildArgs = () => ({
sources: sources!,
destination: dest!,
options: {
config: configGroup.options,
move: moveGroup.options,
filter: filterGroup.options,
remotes: remotesGroup.options,
},
})
const startMoveMutation = useMutation({
mutationFn: async () => {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
}
return startMove(buildArgs())
},
onSuccess: () => {
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
onError: onErrorDialog('Move', 'Failed to start move', {
capture: false,
log: ['Error starting move:'],
}),
})
const scheduleTaskMutation = useScheduleTask({
operation: 'move',
cronExpression,
validate: () => {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
}
if (sources.length > 1 && !usePersistedStore.getState().licenseValid) {
throw new Error('You need a valid license to schedule multiple tasks at once')
}
},
buildArgs,
})
const dryRunMutation = useOperationDryRun(async () => {
if (!sources || sources.length === 0 || !dest) {
throw new Error('Please select both a source and destination path')
}
return startDryRun(() =>
startMove({
sources,
destination: dest,
options: {
config: { ...configGroup.options, dry_run: true },
move: moveGroup.options,
filter: filterGroup.options,
remotes: remotesGroup.options,
},
})
)
})
const buttonText = useMemo(() => {
if (startMoveMutation.isPending) return 'STARTING...'
if (!sources || sources.length === 0) return 'Please select a source path'
if (!dest) return 'Please select a destination path'
if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE MOVE'
return 'START MOVE'
}, [startMoveMutation.isPending, sources, dest, jsonError, cronExpression])
const buttonIcon = useMemo(() => {
if (startMoveMutation.isPending) return
if (!sources || sources.length === 0 || !dest || sources.some((s) => s === dest))
return <FoldersIcon className="w-5 h-5" />
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
return <PlayIcon className="w-5 h-5 fill-current" />
}, [startMoveMutation.isPending, sources, dest, jsonError])
const accordionItems = useMemo<OptionsAccordionItemDef[]>(
() => [
{
key: 'move',
category: 'move',
subtitle: getOptionsSubtitle(Object.keys(moveGroup.options).length),
children: (
<OptionsSection
globalOptions={globalFlags?.main || {}}
optionsJson={moveGroup.jsonString}
setOptionsJson={moveGroup.setJsonString}
availableOptions={copyFlags || []}
isLocked={moveGroup.locked}
setIsLocked={moveGroup.setLocked}
/>
),
},
{
key: 'filters',
category: 'filters',
subtitle: getOptionsSubtitle(Object.keys(filterGroup.options).length),
children: (
<OptionsSection
globalOptions={globalFlags?.filter || {}}
optionsJson={filterGroup.jsonString}
setOptionsJson={filterGroup.setJsonString}
availableOptions={filterFlags || []}
isLocked={filterGroup.locked}
setIsLocked={filterGroup.setLocked}
/>
),
},
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
{
key: 'config',
category: 'config',
subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length),
children: (
<OptionsSection
globalOptions={globalFlags?.main || {}}
optionsJson={configGroup.jsonString}
setOptionsJson={configGroup.setJsonString}
availableOptions={configFlags || []}
isLocked={configGroup.locked}
setIsLocked={configGroup.setLocked}
/>
),
},
...(selectedRemotes.length > 0
? [
{
key: 'remotes',
category: 'remotes' as const,
subtitle: getOptionsSubtitle(
Object.values(remotesGroup.options).reduce(
(acc, opts) => acc + Object.keys(opts).length,
0
)
),
children: (
<RemoteOptionsSection
selectedRemotes={selectedRemotes}
remoteOptionsJson={remotesGroup.json}
setRemoteOptionsJson={remotesGroup.setJson}
reconcileRemotes={remotesGroup.reconcile}
setRemoteOptionsLocked={remotesGroup.setLocked}
remoteOptionsLocked={remotesGroup.locked}
/>
),
},
]
: []),
],
[
moveGroup,
filterGroup,
configGroup,
remotesGroup,
globalFlags,
filterFlags,
configFlags,
copyFlags,
cronExpression,
selectedRemotes,
]
)
const handleStart = useCallback(() => startMoveMutation.mutate(), [startMoveMutation.mutate])
const handleSchedule = useCallback(
() => scheduleTaskMutation.mutate(),
[scheduleTaskMutation.mutate]
)
const handleDryRun = useCallback(() => dryRunMutation.mutate(), [dryRunMutation.mutate])
const handleResetPaths = useCallback(() => {
startTransition(() => {
setSources(undefined)
setDest(undefined)
setJsonError(null)
startMoveMutation.reset()
})
}, [setJsonError, startMoveMutation.reset])
const handleResetOptions = useCallback(() => {
startTransition(() => {
resetJson()
setCronExpression(null)
startMoveMutation.reset()
})
}, [resetJson, startMoveMutation.reset])
const handleResetAll = useCallback(() => {
startTransition(() => {
resetJson()
resetLocks()
setCronExpression(null)
setSources(undefined)
setDest(undefined)
startMoveMutation.reset()
})
}, [resetJson, resetLocks, startMoveMutation.reset])
return (
<div className="flex flex-col h-screen gap-10">
{/* Main Content */}
<OperationWindowContent>
{/* Paths Display */}
<MultiPathFinder
sourcePaths={sources}
setSourcePaths={setSources}
destPath={dest}
setDestPath={setDest}
/>
<OptionsAccordion banner={true} items={accordionItems} />
</OperationWindowContent>
<OperationWindowFooter>
<OperationFooter
operation="move"
templatesDisabled={!!jsonError}
onTemplateSelect={applyTemplate}
getTemplateOptions={getMergedOptions}
startIsSuccess={startMoveMutation.isSuccess}
startIsPending={startMoveMutation.isPending}
onStart={handleStart}
onSchedule={handleSchedule}
dryRunIsPending={dryRunMutation.isPending}
onDryRun={handleDryRun}
startBlocked={
!!jsonError ||
!sources ||
sources.length === 0 ||
!dest ||
sources.some((s) => s === dest)
}
buttonText={buttonText}
buttonIcon={buttonIcon}
newLabel="NEW MOVE"
onResetPaths={handleResetPaths}
onResetOptions={handleResetOptions}
onResetAll={handleResetAll}
helpContent={HELP_CONTENT}
/>
<CommandsDropdown currentCommand="move" />
</ButtonGroup>
</OperationWindowFooter>
</div>
)
+188 -349
View File
@@ -1,363 +1,29 @@
import {
Accordion,
AccordionItem,
Avatar,
Button,
ButtonGroup,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
Tooltip,
} from '@heroui/react'
import * as Sentry from '@sentry/browser'
import { useMutation } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core'
import { message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import cronstrue from 'cronstrue'
import { AnimatePresence, motion } from 'framer-motion'
import { AlertOctagonIcon, ClockIcon, FoldersIcon, PlayIcon, WrenchIcon } from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
import { startTransition, useCallback, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks'
import notify from '../../lib/notify'
import { startPurge } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { useHostStore } from '../../store/host'
import type { FlagValue } from '../../types/rclone'
import CommandInfoButton from '../components/CommandInfoButton'
import CommandsDropdown from '../components/CommandsDropdown'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection'
import { PathField } from '../components/PathFinder'
import TemplatesDropdown from '../components/TemplatesDropdown'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
} from '../components/operation/OptionsAccordion'
import { useOptionGroups } from '../components/operation/useOptionGroups'
import { useScheduleTask } from '../components/operation/useScheduleTask'
export default function Purge() {
const [searchParams] = useSearchParams()
const { globalFlags, configFlags } = useFlags()
const PATH_ALLOWED_KEYS: ('LOCAL_FS' | 'FAVORITES' | 'REMOTES')[] = ['REMOTES', 'FAVORITES']
const [source, setSource] = useState<string | undefined>(
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
)
const DEFAULT_EXPANDED_KEYS = ['config', 'cron']
const [cronExpression, setCronExpression] = useState<string | null>(null)
const [jsonError, setJsonError] = useState<'config' | null>(null)
const [configOptionsLocked, setConfigOptionsLocked] = useState(false)
const [configOptions, setConfigOptions] = useState<Record<string, FlagValue>>({})
const [configOptionsJsonString, setConfigOptionsJsonString] = useState<string>('{}')
const startPurgeMutation = useMutation({
mutationFn: async () => {
if (!source) {
throw new Error('Please select a source path')
}
return startPurge({
sources: [source],
options: {
config: configOptions,
},
})
},
onSuccess: async () => {
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
onError: async (error) => {
console.error('[Purge] Failed to start purge:', error)
Sentry.captureException(error)
await message(error instanceof Error ? error.message : 'Failed to start purge', {
title: 'Purge',
kind: 'error',
})
},
})
const scheduleTaskMutation = useMutation({
mutationFn: async () => {
if (!source) {
throw new Error('Please select a source path to purge')
}
if (!cronExpression) {
throw new Error('Please enter a cron expression')
}
try {
cronstrue.toString(cronExpression)
} catch {
throw new Error('Invalid cron expression')
}
const name = await invoke<string | null>('prompt', {
title: 'Schedule Name',
message: 'Enter a name for this schedule',
default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`,
})
if (!name) {
throw new Error('Schedule name is required')
}
useHostStore.getState().addScheduledTask({
name,
operation: 'purge',
cron: cronExpression,
args: {
sources: [source],
options: {
config: configOptions,
},
},
})
},
onSuccess: async () => {
await notify({
title: 'Success',
body: 'New schedule has been created',
})
},
onError: async (error) => {
console.error('Error scheduling task:', error)
await message(error instanceof Error ? error.message : 'Failed to schedule task', {
title: 'Schedule',
kind: 'error',
})
},
})
const buttonText = useMemo(() => {
if (startPurgeMutation.isPending) return 'STARTING...'
if (!source) return 'Please select a source path'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE PURGE'
return 'START PURGE'
}, [startPurgeMutation.isPending, source, jsonError, cronExpression])
const buttonIcon = useMemo(() => {
if (startPurgeMutation.isPending) return
if (!source) return <FoldersIcon className="w-5 h-5" />
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
return <PlayIcon className="w-5 h-5 fill-current" />
}, [startPurgeMutation.isPending, source, jsonError])
useEffect(() => {
startTransition(() => {
setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2))
})
}, [])
useEffect(() => {
const step = 'config'
try {
const parsedConfig = JSON.parse(configOptionsJsonString) as Record<string, FlagValue>
startTransition(() => {
setConfigOptions(parsedConfig)
setJsonError(null)
})
} catch (error) {
setJsonError(step)
console.error(`Error parsing ${step} options:`, error)
}
}, [configOptionsJsonString])
return (
<div className="flex flex-col h-screen gap-10">
{/* Main Content */}
<OperationWindowContent>
{/* Path Display */}
<PathField
path={source || ''}
setPath={setSource}
label="Path"
placeholder="Enter a remote:/path to purge"
showPicker={true}
allowedKeys={['REMOTES', 'FAVORITES']}
showFiles={false}
/>
<Accordion
keepContentMounted={true}
dividerProps={{
className: 'opacity-50',
}}
defaultExpandedKeys={['config', 'cron']}
>
<AccordionItem
key="config"
startContent={
<Avatar color="default" radius="lg" fallback={<WrenchIcon />} />
}
indicator={<WrenchIcon />}
title="Config"
subtitle={getOptionsSubtitle(Object.keys(configOptions).length)}
>
<OptionsSection
globalOptions={globalFlags?.main || {}}
optionsJson={configOptionsJsonString}
setOptionsJson={setConfigOptionsJsonString}
availableOptions={configFlags || []}
isLocked={configOptionsLocked}
setIsLocked={setConfigOptionsLocked}
/>
</AccordionItem>
<AccordionItem
key="cron"
startContent={
<Avatar color="warning" radius="lg" fallback={<ClockIcon />} />
}
indicator={<ClockIcon />}
title="Cron"
>
<CronEditor expression={cronExpression} onChange={setCronExpression} />
</AccordionItem>
</Accordion>
</OperationWindowContent>
<OperationWindowFooter>
<TemplatesDropdown
isDisabled={!!jsonError}
operation="purge"
onSelect={(groupedOptions, shouldMerge) => {
startTransition(() => {
if (shouldMerge) {
if (groupedOptions.config)
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2))
} else if (groupedOptions.config)
setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2))
})
}}
getOptions={() => ({
...configOptions,
})}
/>
<AnimatePresence mode="wait" initial={false}>
{startPurgeMutation.isSuccess ? (
<motion.div
key="started-buttons"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex flex-1 gap-2"
>
<Dropdown shadow={platform() === 'windows' ? 'none' : undefined}>
<DropdownTrigger>
<Button
fullWidth={true}
color="primary"
size="lg"
data-focus-visible="false"
>
NEW PURGE
</Button>
</DropdownTrigger>
<DropdownMenu>
<DropdownItem
key="reset-paths"
onPress={() => {
startTransition(() => {
setSource(undefined)
setJsonError(null)
startPurgeMutation.reset()
})
}}
>
Reset Path
</DropdownItem>
<DropdownItem
key="reset-options"
onPress={() => {
startTransition(() => {
setConfigOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.config,
null,
2
)
)
setCronExpression(null)
setJsonError(null)
startPurgeMutation.reset()
})
}}
>
Reset Options
</DropdownItem>
<DropdownItem
key="reset-all"
onPress={() => {
startTransition(() => {
setConfigOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.config,
null,
2
)
)
setConfigOptionsLocked(false)
setCronExpression(null)
setJsonError(null)
setSource(undefined)
startPurgeMutation.reset()
})
}}
>
Reset All
</DropdownItem>
</DropdownMenu>
</Dropdown>
</motion.div>
) : (
<motion.div
key="start-button"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex flex-1"
>
<Button
onPress={() => setTimeout(() => startPurgeMutation.mutate(), 100)}
size="lg"
fullWidth={true}
type="button"
color="primary"
isDisabled={startPurgeMutation.isPending || !!jsonError || !source}
isLoading={startPurgeMutation.isPending}
endContent={buttonIcon}
className="max-w-2xl gap-2"
data-focus-visible="false"
>
{buttonText}
</Button>
</motion.div>
)}
</AnimatePresence>
<ButtonGroup variant="flat">
<Tooltip content="Schedule task" placement="top" size="lg" color="foreground">
<Button
size="lg"
type="button"
color="primary"
isIconOnly={true}
onPress={() => {
setTimeout(() => scheduleTaskMutation.mutate(), 100)
}}
>
<ClockIcon className="size-6" />
</Button>
</Tooltip>
<CommandInfoButton
content={`Removes a path and ALL of its contents.
const HELP_CONTENT = `Removes a path and ALL of its contents.
Purge completely deletes the specified directory and everything inside it files, subdirectories, everything. This is a destructive operation that cannot be undone.
@@ -381,10 +47,183 @@ Expand the accordion sections to customize your purge operation. Tap any chip on
Tap the folder icon in the bottom bar to load or save option presets.
4. START THE PURGE
Once a path is selected, tap "START PURGE" to begin. The entire directory and all its contents will be permanently deleted.`}
Once a path is selected, tap "START PURGE" to begin. The entire directory and all its contents will be permanently deleted.`
export default function Purge() {
const [searchParams] = useSearchParams()
const { globalFlags, configFlags } = useFlags()
const [source, setSource] = useState<string | undefined>(
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
)
const [cronExpression, setCronExpression] = useState<string | null>(null)
const {
jsonError,
setJsonError,
groups: optionGroups,
applyTemplate,
getMergedOptions,
resetJson,
resetLocks,
} = useOptionGroups({
groups: [{ key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config }],
})
const configGroup = optionGroups.config
const buildArgs = () => ({
sources: [source!],
options: {
config: configGroup.options,
},
})
const startPurgeMutation = useMutation({
mutationFn: async () => {
if (!source) {
throw new Error('Please select a source path')
}
return startPurge(buildArgs())
},
onSuccess: async () => {
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
onError: onErrorDialog('Purge', 'Failed to start purge', {
log: ['[Purge] Failed to start purge:'],
}),
})
const scheduleTaskMutation = useScheduleTask({
operation: 'purge',
cronExpression,
validate: () => {
if (!source) {
throw new Error('Please select a source path to purge')
}
},
buildArgs,
})
const buttonText = useMemo(() => {
if (startPurgeMutation.isPending) return 'STARTING...'
if (!source) return 'Please select a source path'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE PURGE'
return 'START PURGE'
}, [startPurgeMutation.isPending, source, jsonError, cronExpression])
const buttonIcon = useMemo(() => {
if (startPurgeMutation.isPending) return
if (!source) return <FoldersIcon className="w-5 h-5" />
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
return <PlayIcon className="w-5 h-5 fill-current" />
}, [startPurgeMutation.isPending, source, jsonError])
const accordionItems = useMemo<OptionsAccordionItemDef[]>(
() => [
{
key: 'config',
category: 'config',
subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length),
children: (
<OptionsSection
globalOptions={globalFlags?.main || {}}
optionsJson={configGroup.jsonString}
setOptionsJson={configGroup.setJsonString}
availableOptions={configFlags || []}
isLocked={configGroup.locked}
setIsLocked={configGroup.setLocked}
/>
),
},
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
],
[configGroup, globalFlags, configFlags, cronExpression]
)
const handleStart = useCallback(() => startPurgeMutation.mutate(), [startPurgeMutation.mutate])
const handleSchedule = useCallback(
() => scheduleTaskMutation.mutate(),
[scheduleTaskMutation.mutate]
)
const handleResetPaths = useCallback(() => {
startTransition(() => {
setSource(undefined)
setJsonError(null)
startPurgeMutation.reset()
})
}, [setJsonError, startPurgeMutation.reset])
const handleResetOptions = useCallback(() => {
startTransition(() => {
resetJson()
setCronExpression(null)
startPurgeMutation.reset()
})
}, [resetJson, startPurgeMutation.reset])
const handleResetAll = useCallback(() => {
startTransition(() => {
resetJson()
resetLocks()
setCronExpression(null)
setSource(undefined)
startPurgeMutation.reset()
})
}, [resetJson, resetLocks, startPurgeMutation.reset])
return (
<div className="flex flex-col h-screen gap-10">
{/* Main Content */}
<OperationWindowContent>
{/* Path Display */}
<PathField
path={source || ''}
setPath={setSource}
label="Path"
placeholder="Enter a remote:/path to purge"
showPicker={true}
allowedKeys={PATH_ALLOWED_KEYS}
showFiles={false}
/>
<OptionsAccordion
defaultExpandedKeys={DEFAULT_EXPANDED_KEYS}
items={accordionItems}
/>
</OperationWindowContent>
<OperationWindowFooter>
<OperationFooter
operation="purge"
templatesDisabled={!!jsonError}
onTemplateSelect={applyTemplate}
getTemplateOptions={getMergedOptions}
startIsSuccess={startPurgeMutation.isSuccess}
startIsPending={startPurgeMutation.isPending}
onStart={handleStart}
onSchedule={handleSchedule}
startBlocked={!!jsonError || !source}
buttonText={buttonText}
buttonIcon={buttonIcon}
newLabel="NEW PURGE"
showViewTransfers={false}
resetPathsLabel="Reset Path"
onResetPaths={handleResetPaths}
onResetOptions={handleResetOptions}
onResetAll={handleResetAll}
helpContent={HELP_CONTENT}
/>
<CommandsDropdown currentCommand="purge" />
</ButtonGroup>
</OperationWindowFooter>
</div>
)
+11 -9
View File
@@ -8,6 +8,7 @@ import { formatDistance } from 'date-fns'
import { AlertCircleIcon, Clock7Icon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { buildReadablePath } from '../../lib/format'
import { useNow } from '../../lib/hooks'
import { useHostStore } from '../../store/host'
import type { ScheduledTask } from '../../types/schedules'
import CommandsDropdown from '../components/CommandsDropdown'
@@ -69,10 +70,14 @@ function TaskCard({
}
}, [task.name, isEditingName])
// The card's time-derived values are anchored to this tick — without it the memos freeze at
// their last dep change (e.g. a past occurrence kept showing as the "next run" forever).
const now = useNow()
const nextRun = useMemo(() => {
const parsed = CronExpressionParser.parse(task.cron)
const parsed = CronExpressionParser.parse(task.cron, { currentDate: new Date(now) })
return parsed.hasNext() ? parsed.next().toDate() : null
}, [task.cron])
}, [task.cron, now])
const source = useMemo(
() => ('source' in task.args ? task.args.source : task.args.sources[0]),
@@ -81,24 +86,24 @@ function TaskCard({
const nextRunLabel = useMemo(() => {
if (nextRun) {
const distance = formatDistance(nextRun, new Date(), { addSuffix: true })
const distance = formatDistance(nextRun, new Date(now), { addSuffix: true })
return distance.charAt(0).toUpperCase() + distance.slice(1)
}
return 'Never'
}, [nextRun])
}, [nextRun, now])
const lastRunLabel = useMemo(() => {
if (task.isRunning) {
return 'Running now'
}
if (task.lastRun) {
const distance = formatDistance(new Date(task.lastRun), new Date(), {
const distance = formatDistance(new Date(task.lastRun), new Date(now), {
addSuffix: true,
})
return distance.charAt(0).toUpperCase() + distance.slice(1)
}
return 'Never'
}, [task.isRunning, task.lastRun])
}, [task.isRunning, task.lastRun, now])
return (
<Card
@@ -109,9 +114,6 @@ function TaskCard({
onPress={() => onOpenDrawer(task)}
style={{
flexShrink: 0,
// border: '1px solid #e0e0e070',
// borderBottom: '1px solid #e0e0e070',
// padding: '0.5rem',
}}
className="p-2 border-b border-divider"
>
+47 -17
View File
@@ -12,7 +12,6 @@ import {
SelectItem,
Tooltip,
} from '@heroui/react'
import * as Sentry from '@sentry/browser'
import { useMutation } from '@tanstack/react-query'
import { message } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
@@ -30,6 +29,7 @@ import {
} from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks'
import { startServe } from '../../lib/rclone/api'
@@ -90,14 +90,9 @@ export default function Serve() {
...(vfsOptions as Record<string, FlagValue>),
})
},
onError: async (error) => {
console.error('[Serve] Failed to start serve:', error)
Sentry.captureException(error)
await message(error instanceof Error ? error.message : 'Failed to start serve', {
title: 'Serve',
kind: 'error',
})
},
onError: onErrorDialog('Serve', 'Failed to start serve', {
log: ['[Serve] Failed to start serve:'],
}),
})
useEffect(() => {
@@ -290,19 +285,54 @@ export default function Serve() {
startTransition(() => {
if (shouldMerge) {
if (groupedOptions.serve && type)
setServeOptionsJsonString(JSON.stringify({ ...serveOptions, ...groupedOptions.serve[type] }, null, 2))
setServeOptionsJsonString(
JSON.stringify(
{ ...serveOptions, ...groupedOptions.serve[type] },
null,
2
)
)
if (groupedOptions.vfs)
setVfsOptionsJsonString(JSON.stringify({ ...vfsOptions, ...groupedOptions.vfs }, null, 2))
setVfsOptionsJsonString(
JSON.stringify(
{ ...vfsOptions, ...groupedOptions.vfs },
null,
2
)
)
if (groupedOptions.filter)
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2))
setFilterOptionsJsonString(
JSON.stringify(
{ ...filterOptions, ...groupedOptions.filter },
null,
2
)
)
if (groupedOptions.config)
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2))
setConfigOptionsJsonString(
JSON.stringify(
{ ...configOptions, ...groupedOptions.config },
null,
2
)
)
} else {
if (groupedOptions.serve && type)
setServeOptionsJsonString(JSON.stringify(groupedOptions.serve[type], null, 2))
if (groupedOptions.vfs) setVfsOptionsJsonString(JSON.stringify(groupedOptions.vfs, null, 2))
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2))
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2))
setServeOptionsJsonString(
JSON.stringify(groupedOptions.serve[type], null, 2)
)
if (groupedOptions.vfs)
setVfsOptionsJsonString(
JSON.stringify(groupedOptions.vfs, null, 2)
)
if (groupedOptions.filter)
setFilterOptionsJsonString(
JSON.stringify(groupedOptions.filter, null, 2)
)
if (groupedOptions.config)
setConfigOptionsJsonString(
JSON.stringify(groupedOptions.config, null, 2)
)
}
})
}}
+6 -2
View File
@@ -18,11 +18,13 @@ import { useMemo } from 'react'
import rclone from '../../../lib/rclone/client'
import { getDefaultPaths } from '../../../lib/rclone/common'
import { DOUBLE_BACKSLASH_REGEX } from '../../../lib/rclone/constants'
import { useHostStore } from '../../../store/host'
import { selectActiveConfigFile, useHostStore } from '../../../store/host'
import { usePersistedStore } from '../../../store/persisted'
import BaseSection from './BaseSection'
export default function AboutSection() {
const currentConfig = useHostStore((state) => state.activeConfigFile)
const currentConfig = useHostStore(selectActiveConfigFile)
const rclonePath = usePersistedStore((state) => state.rclonePath)
const defaultPathsQuery = useQuery({
queryKey: ['about', 'defaultPaths'],
@@ -74,6 +76,7 @@ export default function AboutSection() {
},
paths: defaultPathsQuery.data,
dirs: dirsQuery.data,
rcloneBinary: rclonePath,
config: {
id: currentConfig?.id,
label: currentConfig?.label,
@@ -88,6 +91,7 @@ export default function AboutSection() {
currentConfig,
defaultPathsQuery.data,
dirsQuery.data,
rclonePath,
]
)
+532
View File
@@ -0,0 +1,532 @@
import { Button, Checkbox, Chip, Input, Progress, Spinner, Tooltip } from '@heroui/react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { message, open } from '@tauri-apps/plugin-dialog'
import {
CheckIcon,
DownloadIcon,
FolderOpenIcon,
HardDriveIcon,
RefreshCwIcon,
Trash2Icon,
} from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { formatErrorMessage, reportError } from '../../../lib/errors'
import { formatBytes } from '../../../lib/format'
import {
classifyRclonePath,
compareVersions,
findSystemRclone,
probeRcloneBinaryOrThrow,
validateRcloneBinary,
} from '../../../lib/rclone/common'
import { MIN_RCLONE_VERSION } from '../../../lib/rclone/constants'
import {
type DownloadProgress,
activateRclonePath,
deleteVersion,
downloadVersion,
fetchAvailableVersions,
getPathIntegration,
listDownloadedVersions,
setPathIntegration,
} from '../../../lib/rclone/versions'
import { usePersistedStore } from '../../../store/persisted'
import BaseSection from './BaseSection'
/** Warning for binaries below the version floor the app's Serve feature needs. */
function subFloorWarning(version: string | null | undefined): string | null {
if (!version) return null
return compareVersions(version, MIN_RCLONE_VERSION) < 0
? `Serve requires rclone ≥ ${MIN_RCLONE_VERSION.split('.').slice(0, 2).join('.')}`
: null
}
export default function BinarySection() {
const queryClient = useQueryClient()
const rclonePath = usePersistedStore((state) => state.rclonePath)
const [progress, setProgress] = useState<Record<string, DownloadProgress>>({})
const downloadedQuery = useQuery({
queryKey: ['rclone', 'downloaded'],
queryFn: listDownloadedVersions,
})
const releasesQuery = useQuery({
queryKey: ['rclone', 'releases'],
queryFn: fetchAvailableVersions,
staleTime: 60 * 60 * 1000,
retry: 1,
})
const systemQuery = useQuery({
queryKey: ['rclone', 'system'],
queryFn: findSystemRclone,
})
const systemVersionQuery = useQuery({
queryKey: ['rclone', 'system-version', systemQuery.data],
queryFn: () => validateRcloneBinary(systemQuery.data!),
enabled: !!systemQuery.data,
})
const classificationQuery = useQuery({
queryKey: ['rclone', 'classify', rclonePath],
queryFn: () => (rclonePath ? classifyRclonePath(rclonePath) : null),
enabled: !!rclonePath,
})
const active = classificationQuery.data
const invalidateActive = () => {
queryClient.invalidateQueries({ queryKey: ['rclone'] })
}
const downloadMutation = useMutation({
mutationFn: async (version: string) => {
return await downloadVersion(version, (p) =>
setProgress((prev) => ({ ...prev, [version]: p }))
)
},
onSettled: (_data, _err, version) => {
setProgress((prev) => {
const next = { ...prev }
delete next[version]
return next
})
queryClient.invalidateQueries({ queryKey: ['rclone', 'downloaded'] })
},
onError: async (e) => {
await message(`Download failed: ${formatErrorMessage(e, String(e))}`, {
title: 'Error',
kind: 'error',
})
},
})
const activateMutation = useMutation({
mutationFn: async (opts: { path: string; isSystem?: boolean }) => {
return await activateRclonePath(opts.path, { offerSystemConfig: opts.isSystem })
},
onSuccess: () => invalidateActive(),
})
const deleteMutation = useMutation({
mutationFn: deleteVersion,
onSettled: () => queryClient.invalidateQueries({ queryKey: ['rclone', 'downloaded'] }),
onError: async (e) => {
await message(`Could not delete: ${formatErrorMessage(e, String(e))}`, {
title: 'Error',
kind: 'error',
})
},
})
const downloadedVersions = downloadedQuery.data ?? []
const downloadedSet = useMemo(
() => new Set(downloadedVersions.map((v) => v.version)),
[downloadedVersions]
)
const availableToDownload = (releasesQuery.data ?? []).filter(
(r) => !downloadedSet.has(r.version)
)
const latestVersion = releasesQuery.data?.[0]?.version
const updateAvailable =
active?.kind === 'managed' &&
active.version &&
latestVersion &&
!downloadedSet.has(latestVersion) &&
active.version !== latestVersion
return (
<BaseSection header={{ title: 'Binary' }}>
<div className="flex flex-col w-full gap-6 px-8 pb-10">
{/* ---- Custom binary ---- */}
<CustomBinaryRow
active={active}
systemPath={systemQuery.data ?? null}
rclonePath={rclonePath}
onActivated={invalidateActive}
/>
{/* ---- PATH integration ---- */}
<PathIntegrationRow
rclonePath={rclonePath}
isSystemActive={active?.kind === 'system'}
/>
{/* ---- Auto update ---- */}
<AutoUpdateRow />
{/* ---- Versions ---- */}
<div className="flex flex-col overflow-hidden border divide-y rounded-large border-divider divide-divider">
{/* System */}
{systemQuery.data && (
<VersionRow
label={
systemVersionQuery.data
? `System — v${systemVersionQuery.data}`
: 'System'
}
sublabel={systemQuery.data}
warning={subFloorWarning(systemVersionQuery.data)}
isActive={active?.kind === 'system'}
actionLabel="Use"
isActivating={activateMutation.isPending}
onActivate={() =>
activateMutation.mutate({
path: systemQuery.data!,
isSystem: true,
})
}
/>
)}
{/* Downloaded (managed) */}
{downloadedVersions.map((v) => {
const isActive = active?.kind === 'managed' && active.version === v.version
return (
<VersionRow
key={v.path}
label={`v${v.version}`}
sublabel={formatBytes(v.sizeBytes)}
warning={subFloorWarning(v.version)}
isActive={isActive}
actionLabel="Use"
isActivating={activateMutation.isPending}
onActivate={() => activateMutation.mutate({ path: v.path })}
onDelete={
isActive ? undefined : () => deleteMutation.mutate(v.version)
}
isDeleting={
deleteMutation.isPending &&
deleteMutation.variables === v.version
}
/>
)
})}
{/* Available to download */}
{availableToDownload.map((r) => {
const prog = progress[r.version]
const percent = prog?.total
? Math.min(100, Math.round((prog.downloaded / prog.total) * 100))
: undefined
const isDownloading =
downloadMutation.isPending && downloadMutation.variables === r.version
return (
<div key={r.version} className="flex items-center gap-3 px-4 py-3">
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm text-neutral-500">v{r.version}</span>
{isDownloading && (
<Progress
aria-label="download progress"
size="sm"
value={percent ?? 0}
isIndeterminate={percent === undefined}
className="mt-1 max-w-52"
/>
)}
</div>
<Button
size="sm"
variant="light"
isIconOnly={true}
isLoading={isDownloading}
onPress={() => downloadMutation.mutate(r.version)}
data-focus-visible="false"
>
<DownloadIcon className="w-4 h-4" />
</Button>
</div>
)
})}
{(downloadedVersions.length > 0 || systemQuery.data) &&
availableToDownload.length === 0 &&
releasesQuery.isError && (
<div className="flex items-center justify-between gap-2 px-4 py-3">
<span className="text-xs text-warning">
Couldn't load available versions (offline or rate-limited).
</span>
<Button
size="sm"
variant="light"
onPress={() => releasesQuery.refetch()}
startContent={<RefreshCwIcon className="w-3.5 h-3.5" />}
data-focus-visible="false"
>
Retry
</Button>
</div>
)}
{releasesQuery.isLoading && downloadedVersions.length === 0 && (
<div className="flex items-center justify-center py-6">
<Spinner size="sm" />
</div>
)}
</div>
{updateAvailable && (
<div className="flex items-center gap-2 -mt-3">
<Chip size="sm" color="primary" variant="flat">
Update available: v{latestVersion}
</Chip>
<Button
size="sm"
color="primary"
variant="flat"
isLoading={
downloadMutation.isPending &&
downloadMutation.variables === latestVersion
}
onPress={async () => {
const path = await downloadMutation.mutateAsync(latestVersion!)
activateMutation.mutate({ path })
}}
data-focus-visible="false"
>
Update &amp; use
</Button>
</div>
)}
</div>
</BaseSection>
)
}
function VersionRow({
label,
sublabel,
warning,
isActive,
actionLabel,
onActivate,
isActivating,
onDelete,
isDeleting,
}: {
label: string
sublabel: string
warning?: string | null
isActive: boolean
actionLabel: string
onActivate: () => void
isActivating?: boolean
onDelete?: () => void
isDeleting?: boolean
}) {
return (
<div className="flex items-center gap-3 px-4 py-3">
<HardDriveIcon className="w-4 h-4 text-neutral-500 shrink-0" />
<div className="flex flex-col flex-1 min-w-0">
<span className="text-sm font-medium">{label}</span>
<span className="text-xs truncate text-neutral-500">{sublabel}</span>
{warning && <span className="text-xs text-warning">{warning}</span>}
</div>
{isActive ? (
<Chip
size="sm"
color="success"
variant="flat"
startContent={<CheckIcon className="w-3 h-3" />}
>
Active
</Chip>
) : (
<Button
size="sm"
variant="flat"
isLoading={isActivating}
onPress={onActivate}
data-focus-visible="false"
>
{actionLabel}
</Button>
)}
{onDelete ? (
<Button
size="sm"
variant="light"
isIconOnly={true}
color="danger"
isLoading={isDeleting}
onPress={onDelete}
data-focus-visible="false"
>
<Trash2Icon className="w-4 h-4" />
</Button>
) : (
<Tooltip content="Can't delete the active version" isDisabled={!isActive}>
<span className="inline-flex">
<Button size="sm" variant="light" isIconOnly={true} isDisabled={true}>
<Trash2Icon className="w-4 h-4" />
</Button>
</span>
</Tooltip>
)}
</div>
)
}
function CustomBinaryRow({
active,
systemPath,
rclonePath,
onActivated,
}: {
active: { kind: string; version: string | null } | null | undefined
systemPath: string | null
rclonePath: string | undefined
onActivated: () => void
}) {
const isCustomActive = active?.kind === 'custom'
const [value, setValue] = useState('')
// Seed with the current custom path, else the detected system rclone.
useEffect(() => {
setValue(isCustomActive && rclonePath ? rclonePath : (systemPath ?? ''))
}, [isCustomActive, rclonePath, systemPath])
const customVersionQuery = useQuery({
queryKey: ['rclone', 'custom-version', rclonePath],
queryFn: () => validateRcloneBinary(rclonePath!),
enabled: isCustomActive && !!rclonePath,
})
const customWarning = isCustomActive ? subFloorWarning(customVersionQuery.data) : null
const useMutationState = useMutation({
mutationFn: async (path: string) => {
const version = await probeRcloneBinaryOrThrow(path)
const ok = await activateRclonePath(path)
return { version, ok }
},
onSuccess: () => onActivated(),
onError: async (e) => {
await reportError(e, { title: 'Invalid binary', fallback: String(e), capture: false })
},
})
const browse = async () => {
const selected = await open({
multiple: false,
directory: false,
title: 'Select rclone binary',
})
if (typeof selected === 'string') {
setValue(selected)
}
}
return (
<div className="flex flex-col gap-2">
<div className="flex gap-2">
<Input
value={value}
onValueChange={setValue}
size="sm"
placeholder="/path/to/rclone"
autoComplete="off"
endContent={
<button
type="button"
onClick={browse}
className="transition-colors text-neutral-400 hover:text-neutral-200"
>
<FolderOpenIcon className="w-4 h-4" />
</button>
}
/>
<Button
size="sm"
variant="flat"
isDisabled={!value}
isLoading={useMutationState.isPending}
onPress={() => useMutationState.mutate(value)}
data-focus-visible="false"
>
Use
</Button>
</div>
{isCustomActive && (
<span className="text-xs text-success">
Currently using a custom binary
{customVersionQuery.data ? ` (v${customVersionQuery.data})` : ''}.
</span>
)}
{customWarning && <span className="text-xs text-warning">{customWarning}</span>}
</div>
)
}
function AutoUpdateRow() {
const autoUpdate = usePersistedStore((state) => state.autoUpdateRclone)
return (
<div className="flex flex-col gap-2">
<Checkbox
isSelected={autoUpdate}
onValueChange={(checked) =>
usePersistedStore.getState().setAutoUpdateRclone(checked)
}
>
Automatically update rclone
</Checkbox>
<span className="text-xs text-neutral-500">
Applies to versions installed by the app. When off, you'll be notified when a new
version is available.
</span>
</div>
)
}
function PathIntegrationRow({
rclonePath,
isSystemActive,
}: {
rclonePath: string | undefined
isSystemActive: boolean
}) {
const queryClient = useQueryClient()
const statusQuery = useQuery({
queryKey: ['rclone', 'path-integration'],
queryFn: getPathIntegration,
})
const toggleMutation = useMutation({
mutationFn: async (enable: boolean) => {
if (!rclonePath) throw new Error('No active rclone to link.')
return await setPathIntegration(enable, rclonePath)
},
onSuccess: () =>
queryClient.invalidateQueries({ queryKey: ['rclone', 'path-integration'] }),
onError: async (e) => {
await reportError(e, { title: 'PATH integration', fallback: String(e), capture: false })
queryClient.invalidateQueries({ queryKey: ['rclone', 'path-integration'] })
},
})
const status = statusQuery.data
return (
<div className="flex flex-col gap-2">
<Checkbox
isSelected={status?.enabled ?? false}
isDisabled={
toggleMutation.isPending ||
statusQuery.isLoading ||
isSystemActive ||
!rclonePath
}
onValueChange={(checked) => toggleMutation.mutate(checked)}
>
Add rclone to PATH
</Checkbox>
{isSystemActive && (
<span className="text-xs text-neutral-500">
The system rclone is already on your PATH.
</span>
)}
{status?.warning && !isSystemActive && (
<span className="text-xs text-warning">{status.warning}</span>
)}
</div>
)
}
+70 -67
View File
@@ -28,11 +28,12 @@ import {
Trash2Icon,
} from 'lucide-react'
import { useMemo, useState } from 'react'
import { onErrorDialog } from '../../../lib/errors'
import { removeConfigPassword, setConfigPassword } from '../../../lib/rclone/api'
import { promptForConfigPassword, restartActiveRclone } from '../../../lib/rclone/cli'
import rclone from '../../../lib/rclone/client'
import { getConfigPath } from '../../../lib/rclone/common'
import { useHostStore } from '../../../store/host'
import { selectActiveConfigFile, useHostStore } from '../../../store/host'
import { usePersistedStore } from '../../../store/persisted'
import type { ConfigFile } from '../../../types/config'
import ConfigCreateDrawer from '../../components/ConfigCreateDrawer'
@@ -44,7 +45,7 @@ export default function ConfigSection() {
const licenseValid = usePersistedStore((state) => state.licenseValid)
const configFiles = useHostStore((state) => state.configFiles)
const activeConfigFile = useHostStore((state) => state.activeConfigFile)
const activeConfigFile = useHostStore(selectActiveConfigFile)
const queryClient = useQueryClient()
@@ -94,14 +95,11 @@ export default function ConfigSection() {
await queryClient.cancelQueries()
await queryClient.resetQueries()
},
onError: async (error) => {
console.error('[switchConfig] failed to switch config', error)
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
title: 'Switch Config',
kind: 'error',
onError: onErrorDialog('Switch Config', undefined, {
okLabel: 'OK',
})
},
capture: false,
log: ['[switchConfig] failed to switch config'],
}),
})
const locateConfigMutation = useMutation({
@@ -109,14 +107,11 @@ export default function ConfigSection() {
const configPath = await getConfigPath({ id: id, validate: true })
await revealItemInDir(configPath)
},
onError: async (error) => {
console.error('[locateConfig] failed to locate config', error)
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
title: 'Failed to locate config',
kind: 'error',
onError: onErrorDialog('Failed to locate config', undefined, {
okLabel: 'OK',
})
},
capture: false,
log: ['[locateConfig] failed to locate config'],
}),
})
const exportConfigMutation = useMutation({
@@ -142,14 +137,11 @@ export default function ConfigSection() {
await writeTextFile(exportPath, text)
},
onError: async (error) => {
console.error('[exportConfig] failed to export config', error)
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
title: 'Failed to export config',
kind: 'error',
onError: onErrorDialog('Failed to export config', undefined, {
okLabel: 'OK',
})
},
capture: false,
log: ['[exportConfig] failed to export config'],
}),
})
const removePasswordMutation = useMutation({
@@ -178,14 +170,11 @@ export default function ConfigSection() {
await removeConfigPassword()
},
onError: async (error) => {
console.error('[removePassword] failed to remove password', error)
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
title: 'Config Encryption',
kind: 'error',
onError: onErrorDialog('Config Encryption', undefined, {
okLabel: 'OK',
})
},
capture: false,
log: ['[removePassword] failed to remove password'],
}),
})
const setPasswordMutation = useMutation({
@@ -207,14 +196,11 @@ export default function ConfigSection() {
persist: Boolean(activeConfigFile.pass),
})
},
onError: async (error) => {
console.error('[setPassword] failed to set password', error)
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
title: 'Config Encryption',
kind: 'error',
onError: onErrorDialog('Config Encryption', undefined, {
okLabel: 'OK',
})
},
capture: false,
log: ['[setPassword] failed to set password'],
}),
})
const savePasswordMutation = useMutation({
@@ -253,14 +239,11 @@ export default function ConfigSection() {
okLabel: 'OK',
})
},
onError: async (error) => {
console.error('[savePasswordCommand] failed to save password command', error)
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
title: 'Config Password',
kind: 'error',
onError: onErrorDialog('Config Password', undefined, {
okLabel: 'OK',
})
},
capture: false,
log: ['[savePasswordCommand] failed to save password command'],
}),
})
const savePasswordCommandMutation = useMutation({
@@ -307,14 +290,11 @@ export default function ConfigSection() {
okLabel: 'OK',
})
},
onError: async (error) => {
console.error('[savePasswordCommand] failed to save password command', error)
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
title: 'Config Password',
kind: 'error',
onError: onErrorDialog('Config Password', undefined, {
okLabel: 'OK',
})
},
capture: false,
log: ['[savePasswordCommand] failed to save password command'],
}),
})
const removeSavedPasswordMutation = useMutation({
@@ -351,14 +331,11 @@ export default function ConfigSection() {
okLabel: 'OK',
})
},
onError: async (error) => {
console.error('[removeSavedPassword] failed to remove saved password', error)
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
title: 'Config Password',
kind: 'error',
onError: onErrorDialog('Config Password', undefined, {
okLabel: 'OK',
})
},
capture: false,
log: ['[removeSavedPassword] failed to remove saved password'],
}),
})
return (
@@ -515,9 +492,7 @@ function ConfigCard({
const disabled = ['enable']
if (configFile.passCommand) {
disabled.push('save-password', 'save-password-command')
} else if (configFile.pass) {
if (configFile.passCommand || configFile.pass) {
disabled.push('save-password', 'save-password-command')
} else {
disabled.push('remove-password')
@@ -718,22 +693,50 @@ function ConfigCard({
return
}
try {
if (!configFile.sync) {
// validate: false so a manually-deleted
// directory doesn't throw before we can still
// clean up the store entry.
const path = await getConfigPath({
id: configFile.id!,
validate: true,
validate: false,
})
await remove(path.replace('rclone.conf', ''), {
recursive: true,
})
try {
await remove(
path.replace('rclone.conf', ''),
{ recursive: true }
)
} catch (removeError) {
// An already-gone directory is fine — fall
// through and still remove the store entry.
const detail =
removeError instanceof Error
? removeError.message
: String(removeError)
if (
!/no such file|not found|cannot find|does not exist/i.test(
detail
)
) {
throw removeError
}
}
}
if (activeConfigFile?.id === configFile.id) {
useHostStore.getState().setActiveConfigFile('default')
useHostStore
.getState()
.setActiveConfigFile('default')
}
useHostStore.getState().removeConfigFile(configFile.id!)
useHostStore
.getState()
.removeConfigFile(configFile.id!)
} catch (error) {
await onErrorDialog('Delete Config')(error)
}
}, 100)
}}
>
+7 -3
View File
@@ -2,7 +2,6 @@ import { Button, Checkbox, Chip, Input, Select, SelectItem } from '@heroui/react
import * as Sentry from '@sentry/browser'
import { useMutation, useQuery } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { disable, enable } from '@tauri-apps/plugin-autostart'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
@@ -10,6 +9,7 @@ import { platform } from '@tauri-apps/plugin-os'
import { type Update, check } from '@tauri-apps/plugin-updater'
import { EyeIcon } from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { RELAUNCH_APP, emitToMain } from '../../../lib/events'
import notify from '../../../lib/notify'
import { usePersistedStore } from '../../../store/persisted'
import BaseSection from './BaseSection'
@@ -130,7 +130,7 @@ export default function GeneralSection() {
return
}
await getCurrentWindow().emit('relaunch-app')
await emitToMain(RELAUNCH_APP)
},
})
@@ -234,7 +234,11 @@ export default function GeneralSection() {
label="Tray Theme"
selectedKeys={[appearance.tray]}
onSelectionChange={(keys) => {
const value = Array.from(keys)[0] as 'light' | 'dark' | 'system' | 'color'
const value = Array.from(keys)[0] as
| 'light'
| 'dark'
| 'system'
| 'color'
usePersistedStore.setState((state) => ({
appearance: { ...state.appearance, tray: value },
}))
+3 -10
View File
@@ -4,13 +4,13 @@ import { ask, message } from '@tauri-apps/plugin-dialog'
import { PlusIcon, RefreshCcwIcon, Trash2Icon } from 'lucide-react'
import { useMemo, useState } from 'react'
import { type Host, LABEL_FOR_OS, LOCAL_HOST_ID, getHostInfo } from '../../../lib/hosts'
import { usePersistedStore } from '../../../store/persisted'
import { useCurrentHost, usePersistedStore } from '../../../store/persisted'
import HostAddDrawer from '../../components/HostAddDrawer'
import BaseSection from './BaseSection'
export default function HostsSection() {
const hosts = usePersistedStore((state) => state.hosts)
const currentHost = usePersistedStore((state) => state.currentHost)
const currentHost = useCurrentHost()
const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false)
@@ -89,9 +89,7 @@ function HostCard({
return
}
usePersistedStore.setState({
currentHost: host,
})
usePersistedStore.getState().setCurrentHost(host.id)
},
onError: () => {
message('Failed to change host. Please try again.', {
@@ -119,11 +117,6 @@ function HostCard({
usePersistedStore.setState((state) => ({
hosts: state.hosts.map((h) => (h.id === host.id ? { ...h, ...hostInfo } : h)),
}))
if (isActive) {
usePersistedStore.setState((state) => ({
currentHost: { ...state.currentHost!, ...hostInfo },
}))
}
},
onError: () => {
message('Failed to update host. Please try again.', {
+24 -40
View File
@@ -6,8 +6,8 @@ import {
DropdownItem,
DropdownMenu,
DropdownTrigger,
Spinner,
Input,
Spinner,
} from '@heroui/react'
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'
import { ask, message } from '@tauri-apps/plugin-dialog'
@@ -23,7 +23,9 @@ import {
} from 'lucide-react'
import { type ReactNode, startTransition, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../../lib/errors'
import { formatBytes } from '../../../lib/format'
import { remoteConfigQueryOptions } from '../../../lib/hooks'
import rclone from '../../../lib/rclone/client'
import { SUPPORTS_ABOUT } from '../../../lib/rclone/constants'
import { usePersistedStore } from '../../../store/persisted'
@@ -53,42 +55,38 @@ export default function RemotesSection() {
const remoteConfigQueries = useQueries({
queries: remotes.map((remote) => ({
queryKey: ['remotes', remote, 'config', 'sortable'],
queryFn: async () => {
const config = await rclone('/config/get', {
params: { query: { name: remote } },
})
return { remote, type: config?.type ?? null }
},
...remoteConfigQueryOptions(remote),
staleTime: 1000 * 60,
})),
})
const sortedRemotes = useMemo(
() =>
[...remotes].sort((a, b) => {
const configA = remoteConfigQueries.find((q) => q.data?.remote === a)?.data
const configB = remoteConfigQueries.find((q) => q.data?.remote === b)?.data
const sortedRemotes = useMemo(() => {
// useQueries preserves input order, so remoteConfigQueries[i] corresponds to remotes[i].
const typeByRemote = new Map<string, string | null>()
remotes.forEach((remote, i) => {
typeByRemote.set(remote, remoteConfigQueries[i]?.data?.type ?? null)
})
const aSupportsAbout = configA?.type ? SUPPORTS_ABOUT.includes(configA.type) : false
const bSupportsAbout = configB?.type ? SUPPORTS_ABOUT.includes(configB.type) : false
return [...remotes].sort((a, b) => {
const aType = typeByRemote.get(a)
const bType = typeByRemote.get(b)
const aSupportsAbout = aType ? SUPPORTS_ABOUT.includes(aType) : false
const bSupportsAbout = bType ? SUPPORTS_ABOUT.includes(bType) : false
if (aSupportsAbout && !bSupportsAbout) return -1
if (!aSupportsAbout && bSupportsAbout) return 1
return a.localeCompare(b)
}),
[remotes, remoteConfigQueries]
)
})
}, [remotes, remoteConfigQueries])
const [searchQuery, setSearchQuery] = useState('')
const filteredRemotes = useMemo(
() =>
searchQuery
? sortedRemotes.filter((r) =>
r.toLowerCase().includes(searchQuery.toLowerCase())
)
? sortedRemotes.filter((r) => r.toLowerCase().includes(searchQuery.toLowerCase()))
: sortedRemotes,
[sortedRemotes, searchQuery]
)
@@ -116,13 +114,10 @@ export default function RemotesSection() {
...(old ?? []).filter((r) => r !== remote),
])
},
onError: async (error) => {
console.error('Failed to delete remote:', error)
await message(error instanceof Error ? error.message : 'Unknown error occurred', {
title: 'Could not delete remote',
kind: 'error',
})
},
onError: onErrorDialog('Could not delete remote', 'Unknown error occurred', {
capture: false,
log: ['Failed to delete remote:'],
}),
})
const Placeholder = useMemo(() => {
@@ -334,18 +329,7 @@ function RemoteCard({
onConfigPress: () => void
onDeletePress: () => void
}) {
const { data: remoteConfigData } = useQuery({
queryKey: ['remotes', remote, 'config'],
queryFn: async () => {
return await rclone('/config/get', {
params: {
query: {
name: remote,
},
},
})
},
})
const { data: remoteConfigData } = useQuery(remoteConfigQueryOptions(remote))
const type = useMemo(() => remoteConfigData?.type ?? null, [remoteConfigData?.type])
const provider = useMemo(() => remoteConfigData?.provider ?? null, [remoteConfigData?.provider])
+33 -7
View File
@@ -12,6 +12,7 @@ import {
InfoIcon,
KeyboardIcon,
MedalIcon,
PackageIcon,
SatelliteDishIcon,
ServerIcon,
TabletSmartphoneIcon,
@@ -21,8 +22,9 @@ import { useSearchParams } from 'react-router-dom'
import { LOCAL_HOST_ID } from '../../../lib/hosts'
import rclone from '../../../lib/rclone/client'
import { useStore } from '../../../store/memory'
import { usePersistedStore } from '../../../store/persisted'
import { useCurrentHost, usePersistedStore } from '../../../store/persisted'
import AboutSection from './AboutSection'
import BinarySection from './BinarySection'
import ConfigSection from './ConfigSection'
import GeneralSection from './GeneralSection'
import HostsSection from './HostsSection'
@@ -35,7 +37,7 @@ import ToolbarSection from './ToolbarSection'
export default function Settings() {
const [searchParams] = useSearchParams()
const settingsPass = usePersistedStore((state) => state.settingsPass)
const currentHost = usePersistedStore((state) => state.currentHost)
const currentHost = useCurrentHost()
const isRestartingRclone = useStore((state) => state.isRestartingRclone)
const isLocalHost = useMemo(() => currentHost?.id === LOCAL_HOST_ID, [currentHost?.id])
@@ -124,11 +126,7 @@ export default function Settings() {
</Button>
}
/>
<Button
onPress={checkPassword}
data-focus-visible="false"
color="primary"
>
<Button onPress={checkPassword} data-focus-visible="false" color="primary">
Open
</Button>
</div>
@@ -234,6 +232,34 @@ export default function Settings() {
>
<ConfigSection />
</Tab>
<Tab
key="binary"
title={
<Tooltip
content={
currentHost?.id !== 'local'
? 'Rclone settings are 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">
<PackageIcon className="w-5 h-5" />
<span>Binary</span>
</div>
</Tooltip>
}
data-focus-visible="false"
isDisabled={currentHost?.id !== 'local'}
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
>
<BinarySection />
</Tab>
<Tab
key="proxy"
title={
+300 -607
View File
@@ -1,631 +1,51 @@
import {
Accordion,
AccordionItem,
Avatar,
Button,
ButtonGroup,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
Tooltip,
} from '@heroui/react'
import * as Sentry from '@sentry/browser'
import { useMutation } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import cronstrue from 'cronstrue'
import { AnimatePresence, motion } from 'framer-motion'
import {
AlertOctagonIcon,
ClockIcon,
EyeIcon,
FilterIcon,
FolderSyncIcon,
FoldersIcon,
PlayIcon,
ServerIcon,
WrenchIcon,
} from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react'
import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
import { startTransition, useCallback, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks'
import notify from '../../lib/notify'
import { startDryRun, startSync } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { openWindow } from '../../lib/window'
import { useHostStore } from '../../store/host'
import type { FlagValue } from '../../types/rclone'
import CommandInfoButton from '../components/CommandInfoButton'
import CommandsDropdown from '../components/CommandsDropdown'
import CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection'
import { PathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection'
import ShowMoreOptionsBanner from '../components/ShowMoreOptionsBanner'
import TemplatesDropdown from '../components/TemplatesDropdown'
import OperationFooter from '../components/operation/OperationFooter'
import OptionsAccordion, {
type OptionsAccordionItemDef,
} from '../components/operation/OptionsAccordion'
import { useOperationDryRun } from '../components/operation/useOperationDryRun'
import { useOptionGroups } from '../components/operation/useOptionGroups'
import { useScheduleTask } from '../components/operation/useScheduleTask'
export default function Sync() {
const [searchParams] = useSearchParams()
const { globalFlags, filterFlags, configFlags, syncFlags } = useFlags()
const PATH_ALLOWED_KEYS: ('LOCAL_FS' | 'FAVORITES' | 'REMOTES')[] = [
'LOCAL_FS',
'REMOTES',
'FAVORITES',
]
const [source, setSource] = useState<string | undefined>(
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
)
const [dest, setDest] = useState<string | undefined>(
searchParams.get('initialDestination') ? searchParams.get('initialDestination')! : undefined
)
const [jsonError, setJsonError] = useState<'sync' | 'filter' | 'config' | 'remote' | null>(null)
const [syncOptionsLocked, setSyncOptionsLocked] = useState(false)
const [syncOptions, setSyncOptions] = useState<Record<string, FlagValue>>({})
const [syncOptionsJsonString, setSyncOptionsJsonString] = useState<string>('{}')
const [filterOptionsLocked, setFilterOptionsLocked] = useState(false)
const [filterOptions, setFilterOptions] = useState<Record<string, FlagValue>>({})
const [filterOptionsJsonString, setFilterOptionsJsonString] = useState<string>('{}')
const [configOptionsLocked, setConfigOptionsLocked] = useState(false)
const [configOptions, setConfigOptions] = useState<Record<string, FlagValue>>({})
const [configOptionsJsonString, setConfigOptionsJsonString] = useState<string>('{}')
const [remoteOptionsLocked, setRemoteOptionsLocked] = useState(false)
const [remoteOptions, setRemoteOptions] = useState<Record<string, Record<string, FlagValue>>>(
{}
)
const [remoteOptionsJsonString, setRemoteOptionsJsonString] = useState<string>('{}')
const [cronExpression, setCronExpression] = useState<string | null>(null)
const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest])
const startSyncMutation = useMutation({
mutationFn: async () => {
if (!source || !dest) {
throw new Error('Please select both a source and destination path')
}
return startSync({
source: source,
destination: dest,
options: {
config: configOptions,
sync: syncOptions,
filter: filterOptions,
remotes: remoteOptions,
},
})
},
onSuccess: () => {
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
onError: async (error) => {
console.error('Error starting sync:', error)
Sentry.captureException(error)
await message(error instanceof Error ? error.message : 'Failed to start sync', {
title: 'Sync',
kind: 'error',
})
},
})
const scheduleTaskMutation = useMutation({
mutationFn: async () => {
if (!source || !dest) {
throw new Error('Please select both a source and destination path')
}
if (!cronExpression) {
throw new Error('Please enter a cron expression')
}
try {
cronstrue.toString(cronExpression)
} catch {
throw new Error('Invalid cron expression')
}
const name = await invoke<string | null>('prompt', {
title: 'Schedule Name',
message: 'Enter a name for this schedule',
default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`,
})
if (!name) {
throw new Error('Schedule name is required')
}
useHostStore.getState().addScheduledTask({
name,
operation: 'sync',
cron: cronExpression,
args: {
source,
destination: dest,
options: {
config: configOptions,
sync: syncOptions,
filter: filterOptions,
remotes: remoteOptions,
},
},
})
},
onSuccess: async () => {
await notify({
title: 'Success',
body: 'New schedule has been created',
})
},
onError: async (error) => {
console.error('Error scheduling task:', error)
await message(error instanceof Error ? error.message : 'Failed to schedule task', {
title: 'Schedule',
kind: 'error',
})
},
})
const dryRunMutation = useMutation({
mutationFn: async () => {
if (!source || !dest) {
throw new Error('Please select both a source and destination path')
}
return startDryRun(() =>
startSync({
source,
destination: dest,
options: {
config: { ...configOptions, dry_run: true },
sync: syncOptions,
filter: filterOptions,
remotes: remoteOptions,
},
})
)
},
onSuccess: async () => {
const result = await ask(
'Dry run started, you can check the results in the Transfers screen',
{
title: 'Preview (Dry Run)',
kind: 'info',
okLabel: 'Open Transfers',
cancelLabel: 'OK',
}
)
if (result) {
await openWindow({ name: 'Transfers', url: '/transfers' })
}
},
onError: async (error) => {
console.error('Error starting dry run:', error)
await message(error instanceof Error ? error.message : 'Failed to start dry run', {
title: 'Dry Run',
kind: 'error',
})
},
})
const buttonText = useMemo(() => {
if (startSyncMutation.isPending) return 'STARTING...'
if (!source) return 'Please select a source path'
if (!dest) return 'Please select a destination path'
if (source === dest) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE SYNC'
return 'START SYNC'
}, [startSyncMutation.isPending, source, dest, jsonError, cronExpression])
const buttonIcon = useMemo(() => {
if (startSyncMutation.isPending) return
if (!source || !dest || source === dest) return <FoldersIcon className="w-5 h-5" />
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
return <PlayIcon className="w-5 h-5 fill-current" />
}, [startSyncMutation.isPending, source, dest, jsonError])
useEffect(() => {
startTransition(() => {
setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2))
setSyncOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.copy, null, 2))
})
}, [])
useEffect(() => {
let step: 'sync' | 'filter' | 'config' | 'remote' = 'sync'
try {
const parsedSync = JSON.parse(syncOptionsJsonString) as Record<string, FlagValue>
step = 'filter'
const parsedFilter = JSON.parse(filterOptionsJsonString) as Record<string, FlagValue>
step = 'config'
const parsedConfig = JSON.parse(configOptionsJsonString) as Record<string, FlagValue>
step = 'remote'
const outerRemote = JSON.parse(remoteOptionsJsonString) as Record<string, string>
const parsedRemote: Record<string, Record<string, FlagValue>> = {}
for (const [key, val] of Object.entries(outerRemote)) {
parsedRemote[key] = JSON.parse(val) as Record<string, FlagValue>
}
startTransition(() => {
setSyncOptions(parsedSync)
setFilterOptions(parsedFilter)
setConfigOptions(parsedConfig)
setRemoteOptions(parsedRemote)
setJsonError(null)
})
} catch (error) {
setJsonError(step)
console.error(`Error parsing ${step} options:`, error)
}
}, [
syncOptionsJsonString,
filterOptionsJsonString,
configOptionsJsonString,
remoteOptionsJsonString,
])
return (
<div className="flex flex-col h-screen gap-10">
{/* Main Content */}
<OperationWindowContent>
{/* Paths Display */}
<PathFinder
sourcePath={source}
setSourcePath={setSource}
destPath={dest}
setDestPath={setDest}
sourceOptions={{
const SOURCE_OPTIONS = {
label: 'Source',
showPicker: true,
placeholder:
'Enter a remote:/path or local path, or tap to select a folder',
placeholder: 'Enter a remote:/path or local path, or tap to select a folder',
clearable: true,
showFiles: true,
allowedKeys: ['LOCAL_FS', 'REMOTES', 'FAVORITES'],
}}
destOptions={{
allowedKeys: PATH_ALLOWED_KEYS,
}
const DEST_OPTIONS = {
label: 'Destination',
showPicker: true,
placeholder: 'Enter a remote:/path or local path',
clearable: true,
showFiles: false,
allowedKeys: ['LOCAL_FS', 'REMOTES', 'FAVORITES'],
}}
/>
allowedKeys: PATH_ALLOWED_KEYS,
}
<div className="relative flex flex-col">
<Accordion
keepContentMounted={true}
dividerProps={{
className: 'opacity-50',
}}
>
<AccordionItem
key="sync"
startContent={
<Avatar color="success" radius="lg" fallback={<FolderSyncIcon />} />
}
indicator={<FolderSyncIcon />}
title="Sync"
subtitle={getOptionsSubtitle(Object.keys(syncOptions).length)}
>
<OptionsSection
optionsJson={syncOptionsJsonString}
setOptionsJson={setSyncOptionsJsonString}
globalOptions={globalFlags?.main || {}}
availableOptions={syncFlags || []}
isLocked={syncOptionsLocked}
setIsLocked={setSyncOptionsLocked}
/>
</AccordionItem>
<AccordionItem
key="filters"
startContent={
<Avatar color="danger" radius="lg" fallback={<FilterIcon />} />
}
indicator={<FilterIcon />}
title="Filters"
subtitle={getOptionsSubtitle(Object.keys(filterOptions).length)}
>
<OptionsSection
globalOptions={globalFlags?.filter || {}}
optionsJson={filterOptionsJsonString}
setOptionsJson={setFilterOptionsJsonString}
availableOptions={filterFlags || []}
isLocked={filterOptionsLocked}
setIsLocked={setFilterOptionsLocked}
/>
</AccordionItem>
<AccordionItem
key="cron"
startContent={
<Avatar color="warning" radius="lg" fallback={<ClockIcon />} />
}
indicator={<ClockIcon />}
title="Cron"
>
<CronEditor expression={cronExpression} onChange={setCronExpression} />
</AccordionItem>
<AccordionItem
key="config"
startContent={
<Avatar color="default" radius="lg" fallback={<WrenchIcon />} />
}
indicator={<WrenchIcon />}
title="Config"
subtitle={getOptionsSubtitle(Object.keys(configOptions).length)}
>
<OptionsSection
globalOptions={globalFlags?.main || {}}
optionsJson={configOptionsJsonString}
setOptionsJson={setConfigOptionsJsonString}
availableOptions={configFlags || []}
isLocked={configOptionsLocked}
setIsLocked={setConfigOptionsLocked}
/>
</AccordionItem>
{selectedRemotes.length > 0 ? (
<AccordionItem
key={'remotes'}
startContent={
<Avatar
className="bg-fuchsia-500"
radius="lg"
fallback={<ServerIcon />}
/>
}
indicator={<ServerIcon />}
title={'Remotes'}
subtitle={getOptionsSubtitle(
Object.values(remoteOptions).reduce(
(acc, opts) => acc + Object.keys(opts).length,
0
)
)}
>
<RemoteOptionsSection
selectedRemotes={selectedRemotes}
remoteOptionsJsonString={remoteOptionsJsonString}
setRemoteOptionsJsonString={setRemoteOptionsJsonString}
setRemoteOptionsLocked={setRemoteOptionsLocked}
remoteOptionsLocked={remoteOptionsLocked}
/>
</AccordionItem>
) : null}
</Accordion>
<ShowMoreOptionsBanner />
</div>
</OperationWindowContent>
<OperationWindowFooter>
<TemplatesDropdown
isDisabled={!!jsonError}
operation="sync"
onSelect={(groupedOptions, shouldMerge) => {
startTransition(() => {
if (shouldMerge) {
if (groupedOptions.sync)
setSyncOptionsJsonString(JSON.stringify({ ...syncOptions, ...groupedOptions.sync }, null, 2))
if (groupedOptions.filter)
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2))
if (groupedOptions.config)
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2))
} else {
if (groupedOptions.sync) setSyncOptionsJsonString(JSON.stringify(groupedOptions.sync, null, 2))
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2))
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2))
}
})
}}
getOptions={() => ({
...syncOptions,
...filterOptions,
...configOptions,
})}
/>
<AnimatePresence mode="wait" initial={false}>
{startSyncMutation.isSuccess ? (
<motion.div
key="started-buttons"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex flex-1 gap-2"
>
<Dropdown shadow={platform() === 'windows' ? 'none' : undefined}>
<DropdownTrigger>
<Button
fullWidth={true}
color="primary"
size="lg"
data-focus-visible="false"
>
NEW SYNC
</Button>
</DropdownTrigger>
<DropdownMenu>
<DropdownItem
key="reset-paths"
onPress={() => {
startTransition(() => {
setSource(undefined)
setDest(undefined)
setJsonError(null)
startSyncMutation.reset()
})
}}
>
Reset Paths
</DropdownItem>
<DropdownItem
key="reset-options"
onPress={() => {
startTransition(() => {
setSyncOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.copy,
null,
2
)
)
setFilterOptionsJsonString('{}')
setConfigOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.config,
null,
2
)
)
setRemoteOptionsJsonString('{}')
setCronExpression(null)
setJsonError(null)
startSyncMutation.reset()
})
}}
>
Reset Options
</DropdownItem>
<DropdownItem
key="reset-all"
onPress={() => {
startTransition(() => {
setSyncOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.copy,
null,
2
)
)
setFilterOptionsJsonString('{}')
setConfigOptionsJsonString(
JSON.stringify(
RCLONE_CONFIG_DEFAULTS.config,
null,
2
)
)
setRemoteOptionsJsonString('{}')
setSyncOptionsLocked(false)
setFilterOptionsLocked(false)
setConfigOptionsLocked(false)
setRemoteOptionsLocked(false)
setCronExpression(null)
setJsonError(null)
setDest(undefined)
setSource(undefined)
startSyncMutation.reset()
})
}}
>
Reset All
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Button
size="lg"
color="secondary"
fullWidth={true}
onPress={async () => {
await openWindow({
name: 'Transfers',
url: '/transfers',
})
}}
data-focus-visible="false"
>
VIEW TRANSFERS
</Button>
</motion.div>
) : (
<motion.div
key="start-button"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
className="flex flex-1"
>
<Button
onPress={() => setTimeout(() => startSyncMutation.mutate(), 100)}
size="lg"
fullWidth={true}
type="button"
color="primary"
isDisabled={
startSyncMutation.isPending ||
!!jsonError ||
!source ||
!dest ||
source === dest
}
isLoading={startSyncMutation.isPending}
endContent={buttonIcon}
className="max-w-2xl gap-2"
data-focus-visible="false"
>
{buttonText}
</Button>
</motion.div>
)}
</AnimatePresence>
<ButtonGroup variant="flat">
<Tooltip
content="Preview (Dry Run)"
placement="top"
size="lg"
color="foreground"
>
<Button
size="lg"
type="button"
color="primary"
isIconOnly={true}
isLoading={dryRunMutation.isPending}
onPress={() => {
if (
dryRunMutation.isPending ||
!!jsonError ||
!source ||
!dest ||
source === dest
) {
return
}
setTimeout(() => dryRunMutation.mutate(), 100)
}}
>
<EyeIcon className="size-6" />
</Button>
</Tooltip>
<Tooltip content="Schedule task" placement="top" size="lg" color="foreground">
<Button
size="lg"
type="button"
color="primary"
isIconOnly={true}
onPress={() => {
setTimeout(() => scheduleTaskMutation.mutate(), 100)
}}
>
<ClockIcon className="size-6" />
</Button>
</Tooltip>
<CommandInfoButton
content={`Sync the source to the destination, changing the destination only. Doesn't transfer files that are identical on source and destination, testing by size and modification time or MD5SUM. Destination is updated to match source, including deleting files if necessary (except duplicate objects, see below). If you don't want to delete files from destination, use the COPY command instead.
const HELP_CONTENT = `Sync the source to the destination, changing the destination only. Doesn't transfer files that are identical on source and destination, testing by size and modification time or MD5SUM. Destination is updated to match source, including deleting files if necessary (except duplicate objects, see below). If you don't want to delete files from destination, use the COPY command instead.
Files in the destination won't be deleted if there were any errors at any point. Duplicate objects (files with the same name, on those providers that support it) are not yet handled.
@@ -659,10 +79,283 @@ Expand the accordion sections to customize your sync operation. Tap any chip on
Tap the folder icon in the bottom bar to load or save option presets. Templates let you quickly apply common configurations without manually setting each option.
4. START THE SYNC
Once paths are selected, tap "START SYNC" to begin. You can monitor progress on the Transfers page.`}
Once paths are selected, tap "START SYNC" to begin. You can monitor progress on the Transfers page.`
export default function Sync() {
const [searchParams] = useSearchParams()
const { globalFlags, filterFlags, configFlags, syncFlags } = useFlags()
const [source, setSource] = useState<string | undefined>(
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
)
const [dest, setDest] = useState<string | undefined>(
searchParams.get('initialDestination') ? searchParams.get('initialDestination')! : undefined
)
const {
jsonError,
setJsonError,
groups: optionGroups,
remotes: remotesGroup,
applyTemplate,
getMergedOptions,
resetJson,
resetLocks,
} = useOptionGroups({
groups: [
{ key: 'sync', defaults: RCLONE_CONFIG_DEFAULTS.copy },
{ key: 'filter' },
{ key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config },
],
withRemotes: true,
})
const syncGroup = optionGroups.sync
const filterGroup = optionGroups.filter
const configGroup = optionGroups.config
const [cronExpression, setCronExpression] = useState<string | null>(null)
const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest])
const buildArgs = () => ({
source: source!,
destination: dest!,
options: {
config: configGroup.options,
sync: syncGroup.options,
filter: filterGroup.options,
remotes: remotesGroup.options,
},
})
const startSyncMutation = useMutation({
mutationFn: async () => {
if (!source || !dest) {
throw new Error('Please select both a source and destination path')
}
return startSync(buildArgs())
},
onSuccess: () => {
if (cronExpression) {
scheduleTaskMutation.mutate()
}
},
onError: onErrorDialog('Sync', 'Failed to start sync', { log: ['Error starting sync:'] }),
})
const scheduleTaskMutation = useScheduleTask({
operation: 'sync',
cronExpression,
validate: () => {
if (!source || !dest) {
throw new Error('Please select both a source and destination path')
}
},
buildArgs,
})
const dryRunMutation = useOperationDryRun(async () => {
if (!source || !dest) {
throw new Error('Please select both a source and destination path')
}
return startDryRun(() =>
startSync({
source,
destination: dest,
options: {
config: { ...configGroup.options, dry_run: true },
sync: syncGroup.options,
filter: filterGroup.options,
remotes: remotesGroup.options,
},
})
)
})
const buttonText = useMemo(() => {
if (startSyncMutation.isPending) return 'STARTING...'
if (!source) return 'Please select a source path'
if (!dest) return 'Please select a destination path'
if (source === dest) return 'Source and destination cannot be the same'
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
if (cronExpression) return 'START AND SCHEDULE SYNC'
return 'START SYNC'
}, [startSyncMutation.isPending, source, dest, jsonError, cronExpression])
const buttonIcon = useMemo(() => {
if (startSyncMutation.isPending) return
if (!source || !dest || source === dest) return <FoldersIcon className="w-5 h-5" />
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
return <PlayIcon className="w-5 h-5 fill-current" />
}, [startSyncMutation.isPending, source, dest, jsonError])
const accordionItems = useMemo<OptionsAccordionItemDef[]>(
() => [
{
key: 'sync',
category: 'sync',
subtitle: getOptionsSubtitle(Object.keys(syncGroup.options).length),
children: (
<OptionsSection
optionsJson={syncGroup.jsonString}
setOptionsJson={syncGroup.setJsonString}
globalOptions={globalFlags?.main || {}}
availableOptions={syncFlags || []}
isLocked={syncGroup.locked}
setIsLocked={syncGroup.setLocked}
/>
),
},
{
key: 'filters',
category: 'filters',
subtitle: getOptionsSubtitle(Object.keys(filterGroup.options).length),
children: (
<OptionsSection
globalOptions={globalFlags?.filter || {}}
optionsJson={filterGroup.jsonString}
setOptionsJson={filterGroup.setJsonString}
availableOptions={filterFlags || []}
isLocked={filterGroup.locked}
setIsLocked={filterGroup.setLocked}
/>
),
},
{
key: 'cron',
category: 'cron',
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
},
{
key: 'config',
category: 'config',
subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length),
children: (
<OptionsSection
globalOptions={globalFlags?.main || {}}
optionsJson={configGroup.jsonString}
setOptionsJson={configGroup.setJsonString}
availableOptions={configFlags || []}
isLocked={configGroup.locked}
setIsLocked={configGroup.setLocked}
/>
),
},
...(selectedRemotes.length > 0
? [
{
key: 'remotes',
category: 'remotes' as const,
subtitle: getOptionsSubtitle(
Object.values(remotesGroup.options).reduce(
(acc, opts) => acc + Object.keys(opts).length,
0
)
),
children: (
<RemoteOptionsSection
selectedRemotes={selectedRemotes}
remoteOptionsJson={remotesGroup.json}
setRemoteOptionsJson={remotesGroup.setJson}
reconcileRemotes={remotesGroup.reconcile}
setRemoteOptionsLocked={remotesGroup.setLocked}
remoteOptionsLocked={remotesGroup.locked}
/>
),
},
]
: []),
],
[
syncGroup,
filterGroup,
configGroup,
globalFlags,
syncFlags,
filterFlags,
configFlags,
cronExpression,
selectedRemotes,
remotesGroup,
]
)
const handleStart = useCallback(() => startSyncMutation.mutate(), [startSyncMutation.mutate])
const handleSchedule = useCallback(
() => scheduleTaskMutation.mutate(),
[scheduleTaskMutation.mutate]
)
const handleDryRun = useCallback(() => dryRunMutation.mutate(), [dryRunMutation.mutate])
const handleResetPaths = useCallback(() => {
startTransition(() => {
setSource(undefined)
setDest(undefined)
setJsonError(null)
startSyncMutation.reset()
})
}, [setJsonError, startSyncMutation.reset])
const handleResetOptions = useCallback(() => {
startTransition(() => {
resetJson()
setCronExpression(null)
startSyncMutation.reset()
})
}, [resetJson, startSyncMutation.reset])
const handleResetAll = useCallback(() => {
startTransition(() => {
resetJson()
resetLocks()
setCronExpression(null)
setDest(undefined)
setSource(undefined)
startSyncMutation.reset()
})
}, [resetJson, resetLocks, startSyncMutation.reset])
return (
<div className="flex flex-col h-screen gap-10">
{/* Main Content */}
<OperationWindowContent>
{/* Paths Display */}
<PathFinder
sourcePath={source}
setSourcePath={setSource}
destPath={dest}
setDestPath={setDest}
sourceOptions={SOURCE_OPTIONS}
destOptions={DEST_OPTIONS}
/>
<OptionsAccordion banner={true} items={accordionItems} />
</OperationWindowContent>
<OperationWindowFooter>
<OperationFooter
operation="sync"
templatesDisabled={!!jsonError}
onTemplateSelect={applyTemplate}
getTemplateOptions={getMergedOptions}
startIsSuccess={startSyncMutation.isSuccess}
startIsPending={startSyncMutation.isPending}
onStart={handleStart}
onSchedule={handleSchedule}
dryRunIsPending={dryRunMutation.isPending}
onDryRun={handleDryRun}
startBlocked={!!jsonError || !source || !dest || source === dest}
buttonText={buttonText}
buttonIcon={buttonIcon}
newLabel="NEW SYNC"
onResetPaths={handleResetPaths}
onResetOptions={handleResetOptions}
onResetAll={handleResetAll}
helpContent={HELP_CONTENT}
/>
<CommandsDropdown currentCommand="sync" />
</ButtonGroup>
</OperationWindowFooter>
</div>
)
+42 -39
View File
@@ -1,8 +1,9 @@
import { LazyStore } from '@tauri-apps/plugin-store'
import { create } from 'zustand'
import { type StateStorage, createJSONStorage, persist } from 'zustand/middleware'
import { createJSONStorage, persist } from 'zustand/middleware'
import type { ConfigFile } from '../types/config'
import type { ScheduledTask } from '../types/schedules'
import { createTauriStateStorage, waitForStoreHydration } from './lib'
let activeHostId: string | null = null
let activeStore: LazyStore | null = null
@@ -10,15 +11,8 @@ let disposeKeyChange: (() => void) | null = null
export async function initHostStore(hostId: string) {
if (activeHostId === hostId && activeStore) {
async function waitForHostStoreHydration() {
await new Promise((resolve) => setTimeout(resolve, 50))
if (!useHostStore.persist.hasHydrated()) {
await waitForHostStoreHydration()
}
await waitForStoreHydration(() => useHostStore.persist.hasHydrated())
console.log('[waitForHostStoreHydration] host store hydrated')
}
await waitForHostStoreHydration()
return
}
@@ -45,25 +39,6 @@ export async function initHostStore(hostId: string) {
await useHostStore.persist.rehydrate()
}
const getStorage = (): StateStorage => ({
getItem: async (name: string): Promise<string | null> => {
if (!activeStore) return null
// console.log('[HostStore] getItem', { name, host: activeHostId })
return (await activeStore.get(name)) ?? null
},
setItem: async (name: string, value: string): Promise<void> => {
if (!activeStore) return
console.log('[HostStore] setItem', { name, value })
await activeStore.set(name, value)
await activeStore.save()
},
removeItem: async (name: string): Promise<void> => {
if (!activeStore) return
await activeStore.delete(name)
await activeStore.save()
},
})
export interface RemoteConfig {
mountOnStart?: {
enabled: boolean
@@ -103,11 +78,16 @@ interface HostState {
configFiles: ConfigFile[]
addConfigFile: (configFile: ConfigFile) => void
removeConfigFile: (id: string) => void
activeConfigFile: ConfigFile | null
setActiveConfigFile: (configFile: string) => void
activeConfigId: string | null
setActiveConfigFile: (id: string) => void
updateConfigFile: (id: string, configFile: Partial<ConfigFile>) => void
lastSkippedVersion: string | undefined
// Resolved-once location of the "default" rclone config for this host. Pinned so switching
// the rclone binary never relocates where the user's remotes are read from.
defaultConfigPath: string | undefined
setDefaultConfigPath: (path: string | undefined) => void
}
export const useHostStore = create<HostState>()(
@@ -138,7 +118,7 @@ export const useHostStore = create<HostState>()(
>
) => {
const state = get()
const configId = state.activeConfigFile?.id
const configId = state.activeConfigId
if (!configId) {
console.error('No active config file for scheduled task')
@@ -178,29 +158,52 @@ export const useHostStore = create<HostState>()(
set((state) => ({
configFiles: state.configFiles.filter((f) => f.id !== id),
})),
activeConfigFile: null,
activeConfigId: null,
setActiveConfigFile: (id: string) =>
set((state) => ({
activeConfigFile: state.configFiles.find((f) => f.id === id) || null,
activeConfigId: state.configFiles.some((f) => f.id === id) ? id : null,
})),
updateConfigFile: (id: string, configFile: Partial<ConfigFile>) =>
set((state) => ({
configFiles: state.configFiles.map((f) =>
f.id === id ? { ...f, ...configFile } : f
),
activeConfigFile:
state.activeConfigFile?.id === id
? { ...state.activeConfigFile, ...configFile }
: state.activeConfigFile,
})),
lastSkippedVersion: undefined,
defaultConfigPath: undefined,
setDefaultConfigPath: (path: string | undefined) =>
set((_) => ({ defaultConfigPath: path })),
}),
{
name: 'host-store',
storage: createJSONStorage(getStorage),
storage: createJSONStorage(() => createTauriStateStorage(() => activeStore)),
skipHydration: true,
version: 1,
version: 2,
migrate: (persistedState, version) => {
// v1 stored the full active ConfigFile object; v2 stores just its id. Also handles
// the version-1 blob written by the persisted-store's legacy migration, whose
// configFiles can be undefined.
if (version < 2 && persistedState) {
const { activeConfigFile, configFiles, ...rest } = persistedState as {
activeConfigFile?: ConfigFile | null
configFiles?: ConfigFile[]
[key: string]: unknown
}
return {
...rest,
configFiles: configFiles ?? [],
activeConfigId: activeConfigFile?.id ?? null,
}
}
return persistedState
},
}
)
)
/** Resolves the active ConfigFile object from the stored id, or null if it no longer exists. */
export function selectActiveConfigFile(state: HostState): ConfigFile | null {
return state.configFiles.find((f) => f.id === state.activeConfigId) ?? null
}
+40
View File
@@ -0,0 +1,40 @@
import type { LazyStore } from '@tauri-apps/plugin-store'
import type { StateStorage } from 'zustand/middleware'
// Single zustand<->tauri-plugin-store adapter shared by the persisted and per-host stores.
// `getStore` is resolved lazily on every call so the host store can swap its backing file.
// A null store makes every operation a no-op (getItem -> null), which the host store relies on
// before a host has been selected.
export function createTauriStateStorage(getStore: () => LazyStore | null): StateStorage {
return {
getItem: async (name: string): Promise<string | null> => {
const store = getStore()
if (!store) return null
console.log('getItem', { name })
return (await store.get(name)) ?? null
},
setItem: async (name: string, value: string): Promise<void> => {
const store = getStore()
if (!store) return
console.log('setItem', { name })
await store.set(name, value)
await store.save()
},
removeItem: async (name: string): Promise<void> => {
const store = getStore()
if (!store) return
console.log('removeItem', { name })
await store.delete(name)
await store.save()
},
}
}
// 50ms recursive poll until a persist store reports hydration. Callers log around it so each
// store keeps its own identifiable trace.
export async function waitForStoreHydration(hasHydrated: () => boolean): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, 50))
if (!hasHydrated()) {
await waitForStoreHydration(hasHydrated)
}
}
-14
View File
@@ -2,8 +2,6 @@ import { shared } from 'use-broadcast-ts'
import { create } from 'zustand'
interface State {
firstWindow: boolean
startupStatus:
| null
| 'initializing'
@@ -17,11 +15,6 @@ interface State {
isRestartingRclone: boolean
currentTheme: {
app: 'light' | 'dark' | 'system'
tray: 'light' | 'dark' | 'system'
}
cloudflaredTunnel: {
pid: number
url: string
@@ -33,18 +26,11 @@ interface State {
export const useStore = create<State>()(
shared(
(_) => ({
firstWindow: true,
startupStatus: null,
startupDisplayed: false,
isRestartingRclone: false,
currentTheme: {
app: 'dark',
tray: 'system',
},
cloudflaredTunnel: null,
dryRunJobIds: [],
+47 -76
View File
@@ -4,13 +4,14 @@ import { platform } from '@tauri-apps/plugin-os'
import { exit } from '@tauri-apps/plugin-process'
import { LazyStore } from '@tauri-apps/plugin-store'
import { create } from 'zustand'
import { type StateStorage, createJSONStorage, persist } from 'zustand/middleware'
import { createJSONStorage, persist } from 'zustand/middleware'
import type { Host } from '../lib/hosts'
import type { SERVE_TYPES } from '../lib/rclone/constants'
import type { ConfigFile } from '../types/config'
import type { ScheduledTask } from '../types/schedules'
import type { Template } from '../types/template'
import type { RemoteConfig as HostRemoteConfig } from './host'
import { createTauriStateStorage } from './lib'
const store = new LazyStore('store.json')
@@ -33,17 +34,6 @@ interface RemoteConfigV1 {
remoteDefaults?: Record<string, any>
}
type SupportedAction =
| 'tray-mount'
| 'tray-sync'
| 'tray-copy'
| 'tray-serve'
| 'tray-move'
| 'tray-bisync'
| 'tray-delete'
| 'tray-purge'
| 'tray-download'
interface TemplateV1 {
id: string
name: string
@@ -53,12 +43,6 @@ interface TemplateV1 {
interface PersistedStateV1 {
remoteConfigList: Record<string, RemoteConfigV1>
setRemoteConfig: (remote: string, config: RemoteConfigV1) => void
mergeRemoteConfig: (remote: string, config: RemoteConfigV1) => void
disabledActions: SupportedAction[]
setDisabledActions: (actions: SupportedAction[]) => void
proxy:
| {
@@ -70,34 +54,18 @@ interface PersistedStateV1 {
favoritePaths: { remote: string; path: string; added: number }[]
settingsPass: string | undefined
setSettingsPass: (pass: string | undefined) => void
licenseKey: string | undefined
setLicenseKey: (key: string | undefined) => void
licenseValid: boolean
setLicenseValid: (valid: boolean) => void
startOnBoot: boolean
setStartOnBoot: (startOnBoot: boolean) => void
scheduledTasks: ScheduledTask[]
addScheduledTask: (
task: Omit<
ScheduledTask,
'id' | 'isRunning' | 'currentRunId' | 'lastRun' | 'configId' | 'isEnabled'
>
) => void
removeScheduledTask: (id: string) => void
updateScheduledTask: (id: string, task: Partial<ScheduledTask>) => void
templates: TemplateV1[]
configFiles: ConfigFile[]
addConfigFile: (configFile: ConfigFile) => void
removeConfigFile: (id: string) => void
activeConfigFile: ConfigFile | null
setActiveConfigFile: (configFile: string) => void
updateConfigFile: (id: string, configFile: Partial<ConfigFile>) => void
lastSkippedVersion: string | undefined
@@ -126,8 +94,7 @@ interface PersistedStateV2 {
templates: Template[]
hosts: Host[]
currentHost: Host | null
updateHost: (id: Host['id'], host: Partial<Host>) => void
currentHostId: string | null
setCurrentHost: (id: Host['id']) => void
hideStartup: boolean
@@ -138,24 +105,19 @@ interface PersistedStateV2 {
tray: 'light' | 'dark' | 'system' | 'color'
app: 'light' | 'dark' | 'system'
}
}
const getStorage = (store: LazyStore): StateStorage => ({
getItem: async (name: string): Promise<string | null> => {
console.log('getItem', { name })
return (await store.get(name)) ?? null
},
setItem: async (name: string, value: string): Promise<void> => {
console.log('setItem', { name, value })
await store.set(name, value)
await store.save()
},
removeItem: async (name: string): Promise<void> => {
console.log('removeItem', { name })
await store.delete(name)
await store.save()
},
})
// Absolute path of the rclone executable the app runs. Managed downloads live under
// $APPLOCALDATA/rclone-versions/vX/, a system rclone is its PATH location, and a custom
// binary is any other path. `undefined` triggers one-time adoption at startup.
rclonePath: string | undefined
setRclonePath: (path: string | undefined) => void
// Download + switch to new stable rclone releases at startup (managed binaries only).
// When off, the app still checks and notifies once per new version.
autoUpdateRclone: boolean
setAutoUpdateRclone: (enabled: boolean) => void
lastNotifiedRcloneVersion: string | undefined
}
export const usePersistedStore = create<PersistedStateV2>()(
persist(
@@ -180,32 +142,14 @@ export const usePersistedStore = create<PersistedStateV2>()(
templates: [],
hosts: [],
currentHost: null,
updateHost: (id: Host['id'], host: Partial<Host>) =>
currentHostId: null,
setCurrentHost: (id: Host['id']) =>
set((state) => {
if (!state.hosts.some((h) => h.id === id)) {
return {}
}
const hosts = state.hosts.map((h) =>
h.id === id ? { ...h, ...host, id: h.id } : h
)
const currentHost =
state.currentHost?.id === id
? (hosts.find((h) => h.id === id) ?? state.currentHost)
: state.currentHost
return { hosts, currentHost }
}),
setCurrentHost: (id: Host['id']) =>
set((state) => {
const host = state.hosts.find((h) => h.id === id)
if (!host) {
return {}
}
return { currentHost: host }
return { currentHostId: id }
}),
hideStartup: false,
@@ -216,11 +160,18 @@ export const usePersistedStore = create<PersistedStateV2>()(
tray: platform() === 'linux' ? 'color' : 'system',
app: 'dark',
},
rclonePath: undefined,
setRclonePath: (path: string | undefined) => set((_) => ({ rclonePath: path })),
autoUpdateRclone: true,
setAutoUpdateRclone: (enabled: boolean) => set((_) => ({ autoUpdateRclone: enabled })),
lastNotifiedRcloneVersion: undefined,
}),
{
name: 'store',
storage: createJSONStorage(() => getStorage(store)),
version: 2,
storage: createJSONStorage(() => createTauriStateStorage(() => store)),
version: 3,
migrate: async (persistedState, version) => {
if (!persistedState) {
return persistedState as PersistedStateV2
@@ -385,12 +336,32 @@ export const usePersistedStore = create<PersistedStateV2>()(
} as unknown as PersistedStateV2
}
if (version < 3) {
// v2 stored the full current Host object; v3 stores just its id.
const { currentHost, ...rest } = persistedState as PersistedStateV2 & {
currentHost?: Host | null
}
return {
...rest,
currentHostId: currentHost?.id ?? null,
} as PersistedStateV2
}
return persistedState as PersistedStateV2
},
}
)
)
/** Resolves the current Host object from the stored id, or null if it no longer exists. */
export function selectCurrentHost(state: PersistedStateV2): Host | null {
return state.hosts.find((h) => h.id === state.currentHostId) ?? null
}
export function useCurrentHost(): Host | null {
return usePersistedStore(selectCurrentHost)
}
usePersistedStore.persist.onFinishHydration((state) => {
if (state.toolbarShortcut) {
invoke('update_toolbar_shortcut', { shortcut: state.toolbarShortcut })
+44 -61
View File
@@ -1,15 +1,16 @@
import { captureException } from '@sentry/browser'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
import { ask, message } from '@tauri-apps/plugin-dialog'
import { openUrl, revealItemInDir } from '@tauri-apps/plugin-opener'
import { reportError } from '../lib/errors'
import { CLOSE_APP, emitToMain } from '../lib/events'
import notify from '../lib/notify'
import queryClient from '../lib/query'
import type { fetchMountList, fetchServeList } from '../lib/rclone/api'
import rclone from '../lib/rclone/client'
import { SERVE_TYPES, SUPPORTS_CLEANUP, SUPPORTS_PURGE } from '../lib/rclone/constants'
import { openFullWindow } from '../lib/window'
import { usePersistedStore } from '../store/persisted'
import { selectCurrentHost, usePersistedStore } from '../store/persisted'
import { COMMAND_CONFIG, COMMAND_DESCRIPTIONS, COMMAND_KEYWORDS } from './constants'
import type {
ToolbarActionArgs,
@@ -272,7 +273,7 @@ const actions: ToolbarActionDefinition[] = [
for (const mount of activeMounts) {
const mountLabel = formatMountLabel(mount)
if (usePersistedStore.getState().currentHost?.id === 'local') {
if (usePersistedStore.getState().currentHostId === 'local') {
results.push(
createBaseResult(
`Open ${mountLabel}`,
@@ -343,14 +344,12 @@ const actions: ToolbarActionDefinition[] = [
try {
await revealItemInDir(mountPoint)
} catch (error) {
console.error('[toolbar] failed to open mount', error)
await message(
error instanceof Error ? error.message : 'Failed to open mount point',
{
await reportError(error, {
title: 'Open Mount',
kind: 'error',
}
)
fallback: 'Failed to open mount point',
capture: false,
log: ['[toolbar] failed to open mount'],
})
}
return
}
@@ -395,14 +394,12 @@ const actions: ToolbarActionDefinition[] = [
old?.filter((m) => m.MountPoint !== mountPoint) ?? []
)
} catch (error) {
console.error('[toolbar] failed to stop mount', error)
await message(
error instanceof Error ? error.message : 'Failed to stop mount instance',
{
await reportError(error, {
title: 'Stop Mount',
kind: 'error',
}
)
fallback: 'Failed to stop mount instance',
capture: false,
log: ['[toolbar] failed to stop mount'],
})
await queryClient.resetQueries({ queryKey: ['mount', 'list'] })
}
return
@@ -426,16 +423,12 @@ const actions: ToolbarActionDefinition[] = [
})
queryClient.setQueryData(['mount', 'list'], [])
} catch (error) {
console.error('[toolbar] failed to stop all mounts', error)
await message(
error instanceof Error
? error.message
: 'Failed to stop all mount instances',
{
await reportError(error, {
title: 'Stop All Mounts',
kind: 'error',
}
)
fallback: 'Failed to stop all mount instances',
capture: false,
log: ['[toolbar] failed to stop all mounts'],
})
await queryClient.resetQueries({ queryKey: ['mount', 'list'] })
}
return
@@ -570,14 +563,12 @@ const actions: ToolbarActionDefinition[] = [
old?.filter((s) => s.id !== serveId) ?? []
)
} catch (error) {
console.error('[toolbar] failed to stop serve', error)
await message(
error instanceof Error ? error.message : 'Failed to stop serve instance',
{
await reportError(error, {
title: 'Stop Serve',
kind: 'error',
}
)
fallback: 'Failed to stop serve instance',
capture: false,
log: ['[toolbar] failed to stop serve'],
})
}
return
}
@@ -591,16 +582,12 @@ const actions: ToolbarActionDefinition[] = [
})
queryClient.setQueryData(['serve', 'list'], [])
} catch (error) {
console.error('[toolbar] failed to stop all serves', error)
await message(
error instanceof Error
? error.message
: 'Failed to stop all serve instances',
{
await reportError(error, {
title: 'Stop All Serves',
kind: 'error',
}
)
fallback: 'Failed to stop all serve instances',
capture: false,
log: ['[toolbar] failed to stop all serves'],
})
await queryClient.resetQueries({ queryKey: ['serve', 'list'] })
}
return
@@ -803,8 +790,8 @@ const actions: ToolbarActionDefinition[] = [
return
}
const persistedStoreState = usePersistedStore.getState()
const hostUrl = persistedStoreState.currentHost?.url
const currentHost = selectCurrentHost(usePersistedStore.getState())
const hostUrl = currentHost?.url
if (!hostUrl) {
await notify({
@@ -816,10 +803,10 @@ const actions: ToolbarActionDefinition[] = [
try {
let auth: string | undefined
const authUser = persistedStoreState.currentHost?.authUser
const authUser = currentHost?.authUser
if (authUser) {
const authPassword = persistedStoreState.currentHost?.authPassword
const authPassword = currentHost?.authPassword
auth = btoa(`${authUser}:${authPassword ?? ''}`)
}
@@ -1175,7 +1162,7 @@ const actions: ToolbarActionDefinition[] = [
return []
},
onPress: async () => {
await getCurrentWindow().emit('close-app')
await emitToMain(CLOSE_APP)
},
},
{
@@ -1253,14 +1240,12 @@ const actions: ToolbarActionDefinition[] = [
(old: string[] | undefined) => old?.filter((v) => v !== fs) ?? []
)
} catch (error) {
console.error('[toolbar] failed to forget VFS cache', error)
await message(
error instanceof Error ? error.message : 'Failed to clear VFS cache',
{
await reportError(error, {
title: 'VFS Forget',
kind: 'error',
}
)
fallback: 'Failed to clear VFS cache',
capture: false,
log: ['[toolbar] failed to forget VFS cache'],
})
}
return
}
@@ -1274,14 +1259,12 @@ const actions: ToolbarActionDefinition[] = [
})
queryClient.setQueryData(['vfs', 'list'], [])
} catch (error) {
console.error('[toolbar] failed to forget all VFS caches', error)
await message(
error instanceof Error ? error.message : 'Failed to clear all VFS caches',
{
await reportError(error, {
title: 'VFS Forget All',
kind: 'error',
}
)
fallback: 'Failed to clear all VFS caches',
capture: false,
log: ['[toolbar] failed to forget all VFS caches'],
})
}
return
}