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": { "formatter": {
"quoteStyle": "single", "quoteStyle": "single",
"quoteProperties": "preserve", "quoteProperties": "preserve",
"trailingComma": "es5", "trailingCommas": "es5",
"semicolons": "asNeeded" "semicolons": "asNeeded"
}, },
"globals": ["it", "describe", "expect", "test"] "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 { useQuery } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { sortByName } from './flags' import { sortByName } from './flags'
import rclone from './rclone/client' import rclone from './rclone/client'
import { SERVE_TYPES } from './rclone/constants' import { SERVE_TYPES } from './rclone/constants'
// 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() { export function useFlags() {
const allFlagsQuery = useQuery({ const allFlagsQuery = useQuery({
queryKey: ['options', 'all'], queryKey: ['options', 'all'],
+20 -2
View File
@@ -1,4 +1,5 @@
import { fetch as tauriFetch } from '@tauri-apps/plugin-http' import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
import { platform } from '@tauri-apps/plugin-os'
import pRetry from 'p-retry' import pRetry from 'p-retry'
import createRCDClient from 'rclone-sdk' import createRCDClient from 'rclone-sdk'
@@ -14,6 +15,25 @@ export interface Host {
export const LOCAL_HOST_ID = 'local' as const 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 = { export const LABEL_FOR_OS = {
windows: 'Windows', windows: 'Windows',
macos: 'macOS', macos: 'macOS',
@@ -43,8 +63,6 @@ export async function getHostInfo({
authHeader = `Basic ${btoa(`${authUser}:${authPassword}`)}` authHeader = `Basic ${btoa(`${authUser}:${authPassword}`)}`
} }
console.log('[getHostInfo] authHeader', authHeader)
const rcloneClient = createRCDClient({ const rcloneClient = createRCDClient({
baseUrl: url, baseUrl: url,
headers: authHeader headers: authHeader
+59 -47
View File
@@ -3,44 +3,78 @@ import { fetch } from '@tauri-apps/plugin-http'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import { usePersistedStore } from '../store/persisted' import { usePersistedStore } from '../store/persisted'
export async function validateLicense(licenseKey: string) { interface LicenseCallLogs {
console.log('[validateLicense]') 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 let id
try { try {
id = await invoke('get_uid') id = await invoke('get_uid')
} catch (e) { } catch (e) {
console.error('[validateLicense] failed to build unique identifier') console.error(logs.uidFail)
console.error(JSON.stringify(e)) console.error(JSON.stringify(e))
throw new Error('Failed to build unique identifier. Please try again later.') throw new Error('Failed to build unique identifier. Please try again later.')
} }
if (!id) { if (!id) {
console.error('[validateLicense] missing unique identifier') console.error(logs.uidMissing)
throw new Error('Failed to build unique identifier. Please try again later.') 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', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
licenseKey, licenseKey,
id, id,
platform: platform(), ...extraBody,
}), }),
}) })
.then((r) => r.json() as Promise<{ error: string; valid: boolean }>) .then((r) => r.json() as Promise<T>)
.catch((e) => { .catch((e) => {
console.error('[validateLicense] failed to validate license') console.error(logs.fetchFail)
console.error(JSON.stringify(e)) 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) { if (response.error) {
console.error('[validateLicense] failed to validate license') console.error(logs.errorResponse)
throw new Error(validationResponse.error) 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) { if (!validationResponse.valid) {
console.error('[validateLicense] invalid license key') console.error('[validateLicense] invalid license key')
throw new Error('Invalid license key. Please check your license key and try again.') 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) { export async function revokeMachineLicense(licenseKey: string) {
console.log('[revokeMachineLicense]') const revocationResponse = await licenseCall<{ error: string; revoked: boolean }>(
'/api/v1/revoke',
let id licenseKey,
{},
try { 'revoke',
id = await invoke('get_uid') {
} catch (e) { start: '[revokeMachineLicense]',
console.error('[revokeMachineLicense] failed to build unique identifier') uidFail: '[revokeMachineLicense] failed to build unique identifier',
console.error(JSON.stringify(e)) uidMissing: '[revokeMachineLicense] missing unique identifier',
throw new Error('Failed to build unique identifier. Please try again later.') fetchFail: '[revokeMachineLicense] failed to revoke license, fetch failed',
} errorResponse: '[revokeMachineLicense] failed to revoke license, has error response',
}
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({
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)
}
if (!revocationResponse.revoked) { if (!revocationResponse.revoked) {
console.error('[revokeMachineLicense] failed to revoke license, missing revoked response') 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 { persistQueryClient } from '@tanstack/query-persist-client-core'
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister' import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
import { QueryClient } from '@tanstack/react-query' import { QueryClient } from '@tanstack/react-query'
+15 -23
View File
@@ -2,7 +2,7 @@ import * as Sentry from '@sentry/browser'
import { message } from '@tauri-apps/plugin-dialog' import { message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import pRetry from 'p-retry' import pRetry from 'p-retry'
import { useHostStore } from '../../store/host' import { selectActiveConfigFile, useHostStore } from '../../store/host'
import { useStore } from '../../store/memory' import { useStore } from '../../store/memory'
import type { JobItem } from '../../types/jobs' import type { JobItem } from '../../types/jobs'
import type { FlagValue } from '../../types/rclone' import type { FlagValue } from '../../types/rclone'
@@ -108,23 +108,19 @@ function serializeOptions(
} }
async function hasStat(path: string) { async function hasStat(path: string) {
try { // No try/catch: a transport failure must propagate as the real error instead of being
const { root, filePath } = getFsInfo(path) // masked as "Source does not exist". A genuinely missing path returns a response with no
const r = await rclone('/operations/stat', { // item, which still yields false.
params: { const { root, filePath } = getFsInfo(path)
query: { const r = await rclone('/operations/stat', {
fs: root === ':local:' ? ':local:/' : root, params: {
remote: filePath, query: {
}, fs: root === ':local:' ? ':local:/' : root,
remote: filePath,
}, },
}) },
if (!r || !r.item) { })
return false return !!r?.item
}
return true
} catch {
return false
}
} }
export async function startCopy({ export async function startCopy({
@@ -1280,7 +1276,7 @@ export async function removeConfigPassword() {
console.log('[removeConfigPassword]') console.log('[removeConfigPassword]')
const state = useHostStore.getState() const state = useHostStore.getState()
const activeConfig = state.activeConfigFile const activeConfig = selectActiveConfigFile(state)
if (!activeConfig || !activeConfig.id) { if (!activeConfig || !activeConfig.id) {
throw new Error('No active configuration selected.') throw new Error('No active configuration selected.')
@@ -1317,16 +1313,12 @@ export async function setConfigPassword(options: {
console.log('[setConfigPassword]') console.log('[setConfigPassword]')
const state = useHostStore.getState() const state = useHostStore.getState()
const activeConfig = state.activeConfigFile const activeConfig = selectActiveConfigFile(state)
if (!activeConfig || !activeConfig.id) { if (!activeConfig || !activeConfig.id) {
throw new Error('No active configuration selected.') throw new Error('No active configuration selected.')
} }
// if (!activeConfig.isEncrypted) {
// throw new Error('Configuration is not encrypted.')
// }
const password = options.password const password = options.password
if (!password) { if (!password) {
+145 -139
View File
@@ -1,21 +1,27 @@
import * as Sentry from '@sentry/browser' import * as Sentry from '@sentry/browser'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { sep } from '@tauri-apps/api/path' import { sep } from '@tauri-apps/api/path'
import { getAllWindows } from '@tauri-apps/api/window'
import { message } from '@tauri-apps/plugin-dialog' import { message } from '@tauri-apps/plugin-dialog'
import { Command } from '@tauri-apps/plugin-shell' import { selectActiveConfigFile, useHostStore } from '../../store/host'
import { useHostStore } from '../../store/host' import { usePersistedStore } from '../../store/persisted'
import type { ConfigFile } from '../../types/config' import type { ConfigFile } from '../../types/config'
import { RESTART_RCLONE, emitToMain } from '../events'
import { getConfigParentFolder } from '../format' 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 { export interface RcloneCliCommandContext {
command: Command<string> rclonePath: string
args: string[]
activeConfig: ConfigFile activeConfig: ConfigFile
configPath: string configPath: string
configDirectory: string configDirectory: string
env: Record<string, string> env: Record<string, string>
flavour: 'system' | 'internal'
} }
export async function promptForConfigPassword(message: string) { 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( async function validateConfigAccess(
commandName: 'rclone-system' | 'rclone-internal', rclonePath: string,
env: Record<string, string> env: Record<string, string>,
timeoutMs: number | null = 15000
): Promise<{ ): Promise<{
success: boolean success: boolean
timedOut?: boolean
code?: number | null code?: number | null
stderr?: string stderr?: string
error?: Error error?: Error
}> { }> {
console.log('[validateConfigAccess] command:', commandName) console.log('[validateConfigAccess] rclone:', rclonePath)
try { try {
const command = Command.create(commandName, ['config', 'dump'], { env }) const result = await invoke<ExecResult>('exec_rclone', {
const result = await command.execute() path: rclonePath,
args: ['config', 'dump'],
env,
stdinLines: null,
timeoutMs,
})
console.log('[validateConfigAccess] exit code:', result.code) console.log('[validateConfigAccess] exit code:', result.code)
if (result.code === 0) { if (result.code === 0) {
@@ -63,6 +85,9 @@ async function validateConfigAccess(
return { return {
success: false, 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, code: result.code ?? null,
stderr: result.stderr, stderr: result.stderr,
} }
@@ -79,7 +104,7 @@ export async function ensureEncryptedConfigEnv(
activeConfig: ConfigFile, activeConfig: ConfigFile,
env: Record<string, string>, env: Record<string, string>,
autoPromptForPassword: boolean, autoPromptForPassword: boolean,
commandName: 'rclone-system' | 'rclone-internal', rclonePath: string,
promptMessage: string promptMessage: string
) { ) {
console.log('[ensureEncryptedConfigEnv] ensuring encrypted config env for:', activeConfig.id) console.log('[ensureEncryptedConfigEnv] ensuring encrypted config env for:', activeConfig.id)
@@ -93,7 +118,9 @@ export async function ensureEncryptedConfigEnv(
RCLONE_CONFIG_PASS_COMMAND: activeConfig.passCommand, 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) { if (validation.success) {
console.log('[ensureEncryptedConfigEnv] passCommand validation succeeded') console.log('[ensureEncryptedConfigEnv] passCommand validation succeeded')
env.RCLONE_CONFIG_PASS_COMMAND = activeConfig.passCommand env.RCLONE_CONFIG_PASS_COMMAND = activeConfig.passCommand
@@ -146,7 +173,7 @@ export async function ensureEncryptedConfigEnv(
RCLONE_CONFIG_PASS: password, RCLONE_CONFIG_PASS: password,
} }
const validation = await validateConfigAccess(commandName, validationEnv) const validation = await validateConfigAccess(rclonePath, validationEnv)
if (validation.success) { if (validation.success) {
console.log('[ensureEncryptedConfigEnv] password validation succeeded') console.log('[ensureEncryptedConfigEnv] password validation succeeded')
env.RCLONE_CONFIG_PASS = password env.RCLONE_CONFIG_PASS = password
@@ -155,6 +182,16 @@ export async function ensureEncryptedConfigEnv(
console.error('[ensureEncryptedConfigEnv] password validation failed', validation.code) 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') { if (passwordSource === 'stored') {
console.log('[ensureEncryptedConfigEnv] clearing invalid stored password') console.log('[ensureEncryptedConfigEnv] clearing invalid stored password')
if (activeConfigId && updateConfigFile) { 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( async function createRcloneCliCommand(
args: string[], args: string[],
additionalEnv?: Record<string, string>, additionalEnv?: Record<string, string>,
autoPromptForPassword = true autoPromptForPassword = true
): Promise<RcloneCliCommandContext> { ): Promise<RcloneCliCommandContext> {
console.log('[createRcloneCliCommand] creating rclone CLI command with args:', args) console.log('[createRcloneCliCommand] creating rclone CLI command with args:', args)
const env: Record<string, string> = {}
const hostStore = useHostStore.getState() const hostStore = useHostStore.getState()
const activeConfig = hostStore.activeConfigFile const activeConfig = selectActiveConfigFile(hostStore)
if (!activeConfig || !activeConfig.id) { if (!activeConfig || !activeConfig.id) {
throw new Error('No active configuration selected.') throw new Error('No active configuration selected.')
} }
console.log('[createRcloneCliCommand] active config:', activeConfig) const rclonePath = getActiveRclonePath()
let configPath: string let configPath: string
try { try {
@@ -231,150 +315,72 @@ async function createRcloneCliCommand(
throw error throw error
} }
console.log('[createRcloneCliCommand] config path:', configPath)
const configDirectory = getConfigParentFolder(configPath) const configDirectory = getConfigParentFolder(configPath)
console.log('[createRcloneCliCommand] config directory:', configDirectory) console.log('[createRcloneCliCommand] config directory:', configDirectory)
const proxy = hostStore.proxy const env = await buildRcloneEnv({
console.log('[createRcloneCliCommand] proxy:', proxy) activeConfig,
if (proxy?.url) { configDirectory,
env.http_proxy = proxy.url configPath,
env.https_proxy = proxy.url proxy: hostStore.proxy,
env.HTTP_PROXY = proxy.url rclonePath,
env.HTTPS_PROXY = proxy.url autoPromptForPassword,
env.no_proxy = proxy.ignoredHosts.join(',') additionalEnv,
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(
activeConfig,
env,
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')
return { return {
command, rclonePath,
args,
activeConfig, activeConfig,
configPath, configPath,
configDirectory, configDirectory,
env, env,
flavour,
} }
} }
export async function runRcloneCli(args: string[], input: string[] = []) { export async function runRcloneCli(args: string[], input: string[] = []) {
const { command } = await createRcloneCliCommand(args, undefined, true) const { rclonePath, env } = await createRcloneCliCommand(args, undefined, true)
let stdout = ''
let stderr = ''
console.log('[runRcloneCli] running command', 'args:', args, 'input:', input) console.log('[runRcloneCli] running command', 'args:', args, 'input:', input)
return await new Promise<void>((resolve, reject) => { let result: ExecResult
command.stdout.on('data', (line) => { try {
console.log('[runRcloneCli] stdout:', line) result = await invoke<ExecResult>('exec_rclone', {
stdout += line 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) => { } catch (error) {
console.log('[runRcloneCli] stderr:', line) const err = error instanceof Error ? error : new Error(String(error))
stderr += line Sentry.captureException(err)
}) throw err
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) { if (result.code !== 0) {
resolve() const error = new Error(
return `rclone command failed (code ${result.code ?? 'unknown'}): ${result.stderr || result.stdout}`
} )
Sentry.captureException(error)
const error = new Error( throw error
`rclone command failed (code ${event.code ?? 'unknown'}): ${stderr || 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')
}
})
.catch((error) => {
console.log('[runRcloneCli] error:', error)
Sentry.captureException(error)
reject(error)
})
})
} }
export async function restartActiveRclone() { export async function restartActiveRclone() {
try { try {
;(await getAllWindows()) // The main window's store may not have rehydrated this webview's writes before the restart
.filter((window) => window.label === 'main')[0] // runs — carry a full lifecycle snapshot from THIS webview's fresh stores in the payload.
.emit('restart-rclone') const host = useHostStore.getState()
// await getCurrentWindow().emit('restart-rclone') 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) { } catch (error) {
Sentry.captureException(error) Sentry.captureException(error)
console.error('[restartActiveRclone] failed to emit restart event', error) console.error('[restartActiveRclone] failed to emit restart event', error)
+41 -69
View File
@@ -10,7 +10,7 @@ import createRCDClient, {
type OpenApiRequiredKeysOf, type OpenApiRequiredKeysOf,
type RCDClient, type RCDClient,
} from 'rclone-sdk' } from 'rclone-sdk'
import { usePersistedStore } from '../../store/persisted' import { selectCurrentHost, usePersistedStore } from '../../store/persisted'
const RE_RECONNECT = /rclone config reconnect (\S+?):/ const RE_RECONNECT = /rclone config reconnect (\S+?):/
@@ -52,7 +52,7 @@ let client: RCDClient | null = null
function getClient() { function getClient() {
if (!client) { if (!client) {
const currentHost = usePersistedStore.getState().currentHost const currentHost = selectCurrentHost(usePersistedStore.getState())
if (!currentHost) { if (!currentHost) {
console.error('[rclone] No current host') console.error('[rclone] No current host')
throw new Error('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 })?]
: [Init & { [key: string]: unknown }] : [Init & { [key: string]: unknown }]
export default async function rclone< type RequestResult = {
Path extends OpenApiClientPathsWithMethod<RCDClient, 'post'>, error?: unknown
Init extends OpenApiMaybeOptionalInit<Paths[Path], 'post'> = OpenApiMaybeOptionalInit< data?: unknown
Paths[Path], response: Response
'post' }
>,
>( // Shared transport core for the sync (POST) and async (ASYNC) RC calls. The two exported wrappers
path: Path, // differ only in the client method, the log prefix, and the return cast; everything else — client
...init: InitParam<Init> // acquisition and the 3-branch error triage — is identical and has always been patched in both.
): Promise<OpenApiMethodResponse<RCDClient, 'post', Path, Init>> { async function request(mode: 'sync' | 'async', path: string, init: any[]): Promise<unknown> {
console.log('[rclone] REQUEST', path, { const label = mode === 'async' ? 'ASYNC ' : ''
console.log(`[rclone] ${label}REQUEST`, path, {
params: init[0]?.params, params: init[0]?.params,
body: init[0]?.body, body: init[0]?.body,
}) })
@@ -110,10 +112,11 @@ export default async function rclone<
throw new Error('Failed to get client after retries') throw new Error('Failed to get client after retries')
} }
const result = await client.POST( const result = (
path, mode === 'async'
...(init as InitParam<OpenApiMaybeOptionalInit<Paths[Path], 'post'>>) ? await client.ASYNC(path as any, ...(init as [any]))
) : await client.POST(path as any, ...(init as [any]))
) as RequestResult
if (result?.error) { if (result?.error) {
console.error('[rclone] ERROR', path, { error: 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 const data = result.data as { error?: unknown } | undefined
if (data?.error) { if (data?.error) {
console.error('[rclone] DATA ERROR', path, { error: data.error }) console.error('[rclone] DATA ERROR', path, { error: data.error })
const errMsg = const errMsg = typeof data.error === 'string' ? data.error : JSON.stringify(data.error)
typeof data.error === 'string' ? data.error : JSON.stringify(data.error)
await handleReconnectIfNeeded(errMsg) await handleReconnectIfNeeded(errMsg)
throw new Error(errMsg) throw new Error(errMsg)
@@ -142,12 +144,12 @@ export default async function rclone<
throw new Error(`${result.response.status} ${result.response.statusText}`) 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'>, Path extends OpenApiClientPathsWithMethod<RCDClient, 'post'>,
Init extends OpenApiMaybeOptionalInit<Paths[Path], 'post'> = OpenApiMaybeOptionalInit< Init extends OpenApiMaybeOptionalInit<Paths[Path], 'post'> = OpenApiMaybeOptionalInit<
Paths[Path], Paths[Path],
@@ -156,51 +158,21 @@ export async function rcloneAsync<
>( >(
path: Path, path: Path,
...init: InitParam<Init> ...init: InitParam<Init>
): Promise<AsyncJobResponse> { ): Promise<OpenApiMethodResponse<RCDClient, 'post', Path, Init>> {
console.log('[rclone] ASYNC REQUEST', path, { return (await request('sync', path, init)) as OpenApiMethodResponse<
params: init[0]?.params, RCDClient,
body: init[0]?.body, 'post',
}) Path,
Init
const client = await pRetry(() => getClient(), { >
'maxTimeout': 500, }
})
export async function rcloneAsync<
if (!client) { Path extends OpenApiClientPathsWithMethod<RCDClient, 'post'>,
console.error('[rclone] ERROR: Failed to get client after retries', path) Init extends OpenApiMaybeOptionalInit<Paths[Path], 'post'> = OpenApiMaybeOptionalInit<
throw new Error('Failed to get client after retries') Paths[Path],
} 'post'
>,
const result = await client.ASYNC(path, ...(init as [any])) >(path: Path, ...init: InitParam<Init>): Promise<AsyncJobResponse> {
return (await request('async', path, init)) as AsyncJobResponse
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
} }
+110 -180
View File
@@ -1,8 +1,6 @@
import { invoke } from '@tauri-apps/api/core'
import { appLocalDataDir, sep } from '@tauri-apps/api/path' import { appLocalDataDir, sep } from '@tauri-apps/api/path'
import { exists, mkdir, writeTextFile } from '@tauri-apps/plugin-fs' import { exists, mkdir, readTextFile, 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 { useHostStore } from '../../store/host' import { useHostStore } from '../../store/host'
import type { FlagValue } from '../../types/rclone' import type { FlagValue } from '../../types/rclone'
import { getConfigParentFolder } from '../format' import { getConfigParentFolder } from '../format'
@@ -25,71 +23,30 @@ export async function getDefaultPaths() {
} }
} }
export async function getSystemConfigPath() { /** App-private location of the default config, used when there is no system rclone to defer to. */
console.log('[getSystemConfigPath] running system rclone') export async function appPrivateDefaultConfigPath() {
const appLocalDataDirPath = await appLocalDataDir()
const instance = Command.create('rclone-system', [ return appLocalDataDirPath + sep() + 'configs' + sep() + 'default' + sep() + 'rclone.conf'
'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()
}
} }
export async function getConfigPath({ id, validate = true }: { id: string; validate?: boolean }) { export async function getConfigPath({ id, validate = true }: { id: string; validate?: boolean }) {
console.log('[getConfigPath]', id, validate) console.log('[getConfigPath]', id, validate)
const appLocalDataDirPath = await appLocalDataDir() const appLocalDataDirPath = await appLocalDataDir()
console.log('[getConfigPath] appLocalDataDirPath', appLocalDataDirPath)
let configPath = appLocalDataDirPath + sep() + 'configs' + sep() + id + sep() + 'rclone.conf' let configPath = appLocalDataDirPath + sep() + 'configs' + sep() + id + sep() + 'rclone.conf'
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' && (await isSystemRcloneInstalled())) { if (id === 'default') {
const defaultPath = await getSystemConfigPath() const persistedDefault = useHostStore.getState().defaultConfigPath
if (persistedDefault) {
configPath = defaultPath configPath = persistedDefault
console.log('[getConfigPath] configPath', configPath) }
} }
console.log('[getConfigPath] configPath', configPath)
if (validate) { if (validate) {
const configExists = await exists(configPath) const configExists = await exists(configPath)
if (!configExists) { if (!configExists) {
@@ -104,68 +61,102 @@ export async function getConfigPath({ id, validate = true }: { id: string; valid
export async function createConfigFile(path: string) { export async function createConfigFile(path: string) {
console.log('[createConfigFile] path', path) console.log('[createConfigFile] path', path)
const hasConfig = await exists(path).catch(() => false) if (await exists(path).catch(() => false)) {
console.log('[createConfigFile] hasConfig', hasConfig) return
if (!hasConfig) { }
console.log('[createConfigFile] writing space character to default path (1)', path)
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('[findSystemRclone] error', error)
return null
}
}
/** 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 }
}
}
/**
* 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 resolveDefaultConfigPath(): Promise<string> {
const appPrivate = await appPrivateDefaultConfigPath()
try {
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)
}
const system = await findSystemRclone()
if (system) {
try { try {
await writeTextFile(path, '# Empty config file\n') const native = await invoke<string>('rclone_config_path', { path: system })
if (native) {
return native.replace(DOUBLE_BACKSLASH_REGEX, '\\')
}
} catch (error) { } catch (error) {
console.error('[createConfigFile] error', error) console.error('[resolveDefaultConfigPath] failed reading native config path', error)
}
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)
} }
} }
}
/** return appPrivate
* Checks if rclone is installed and accessible from the system PATH
* @returns {Promise<boolean>} True if rclone is installed and working
*/
export async function isSystemRcloneInstalled() {
console.log('[isSystemRcloneInstalled]')
try {
const output = await Command.create('rclone-system').execute()
return (
output.stdout.includes('Available commands') ||
output.stderr.includes('Available commands')
)
} catch {
return false
}
}
/**
* 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]')
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
}
} }
export function parseRcloneOptions(options: Record<string, FlagValue>) { 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 { export function compareVersions(version1: string, version2: string): number {
const parseVersion = (version: string) => { 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 { return {
major: parts[0] || 0, major: parts[0] || 0,
minor: parts[1] || 0, minor: parts[1] || 0,
@@ -198,67 +192,3 @@ export function compareVersions(version1: string, version2: string): number {
} }
return 0 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 RCLONE_CONF_REGEX = /[\/\\]rclone\.conf$/
export const DOUBLE_BACKSLASH_REGEX = /\\\\/g 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 SERVE_TYPES = ['dlna', 'ftp', 'sftp', 'http', 'nfs', 'restic', 's3', 'webdav'] as const
export const SUPPORTS_CLEANUP = [ export const SUPPORTS_CLEANUP = [
@@ -161,69 +167,3 @@ export function supportsPersistentEmptyFolders(backendType?: string | null) {
if (!backendType) return true if (!backendType) return true
return !CANNOT_PERSIST_EMPTY_FOLDERS.includes(backendType.toLowerCase()) 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
+271 -411
View File
@@ -1,144 +1,76 @@
import * as Sentry from '@sentry/browser' import * as Sentry from '@sentry/browser'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { BaseDirectory, appLocalDataDir, appLogDir, sep } from '@tauri-apps/api/path' import { appLogDir, sep } from '@tauri-apps/api/path'
import { tempDir } from '@tauri-apps/api/path'
import { ask, message } from '@tauri-apps/plugin-dialog' import { ask, message } from '@tauri-apps/plugin-dialog'
import { copyFile, exists, mkdir, readTextFile, remove } from '@tauri-apps/plugin-fs' import { exists, readTextFile } from '@tauri-apps/plugin-fs'
import { writeFile } from '@tauri-apps/plugin-fs'
import { fetch } from '@tauri-apps/plugin-http' import { fetch } from '@tauri-apps/plugin-http'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import { exit, relaunch } from '@tauri-apps/plugin-process' import { exit, relaunch } from '@tauri-apps/plugin-process'
import { Command } from '@tauri-apps/plugin-shell' import { selectActiveConfigFile, useHostStore } from '../../store/host'
import { useHostStore } from '../../store/host'
import { useStore } from '../../store/memory' import { useStore } from '../../store/memory'
import { usePersistedStore } from '../../store/persisted' import { usePersistedStore } from '../../store/persisted'
import { getConfigParentFolder } from '../format' import { getConfigParentFolder } from '../format'
import notify from '../notify'
import { openSmallWindow } from '../window' import { openSmallWindow } from '../window'
import { ensureEncryptedConfigEnv } from './cli' import { buildRcloneEnv } from './cli'
import { import {
classifyRclonePath,
compareVersions,
createConfigFile, createConfigFile,
findSystemRclone,
getConfigPath, getConfigPath,
getRcloneVersion, resolveDefaultConfigPath,
getSystemConfigPath, validateRcloneBinary,
isInternalRcloneInstalled,
isSystemRcloneInstalled,
shouldUpdateRclone,
} from './common' } from './common'
import { downloadVersion, listDownloadedVersions } from './versions'
export async function initRclone(args: string[]) { export async function initRclone(args: string[]) {
console.log('[initRclone] starting with args:', args) console.log('[initRclone] starting with args:', args)
const system = !(await invoke<boolean>('is_flatpak')) && (await isSystemRcloneInstalled()) // Resolve which rclone binary to run (adopting a system/legacy binary on first launch).
console.log('[initRclone] system rclone installed:', system) let rclonePath = await resolveActiveRclone()
let internal = await isInternalRcloneInstalled()
console.log('[initRclone] internal rclone installed:', internal)
// rclone not available, let's download it // Nothing installed anywhere — download the latest and adopt it.
if (!system && !internal) { if (!rclonePath) {
console.log('[initRclone] no rclone installation found, provisioning...') console.log('[initRclone] no rclone available, provisioning...')
useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' }) useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' })
await openSmallWindow({ await openSmallWindow({
name: 'Startup', name: 'Startup',
url: '/startup', url: '/startup',
}) })
const success = await provisionRclone()
console.log('[initRclone] provision rclone result:', success) const provisionedPath = await provisionRclone()
if (!success) { console.log('[initRclone] provision rclone result:', provisionedPath)
if (!provisionedPath) {
console.error('[initRclone] provision failed, setting fatal status') console.error('[initRclone] provision failed, setting fatal status')
useStore.setState({ startupStatus: 'fatal' }) useStore.setState({ startupStatus: 'fatal' })
return return
} }
console.log('[initRclone] provision succeeded') usePersistedStore.getState().setRclonePath(provisionedPath)
rclonePath = provisionedPath
useStore.setState({ startupStatus: 'initialized' }) useStore.setState({ startupStatus: 'initialized' })
if (!['windows', 'macos'].includes(platform())) { if (!['windows', 'macos'].includes(platform())) {
usePersistedStore.setState({ hideStartup: true }) usePersistedStore.setState({ hideStartup: true })
} }
internal = true
} }
const rcloneVersion = await getRcloneVersion(system ? 'system' : 'internal') // Check for a newer stable release of a managed binary: auto-update or notify.
console.log('[initRclone] rclone version:', rcloneVersion) rclonePath = await maybeAutoUpdateRclone(rclonePath)
if (shouldUpdateRclone(rcloneVersion)) { // Keep the PATH-integration pointer aimed at the active binary (best-effort).
console.log('[initRclone] needs update') invoke('update_path_pointer', { targetPath: rclonePath }).catch((error) => {
console.warn('[initRclone] update_path_pointer failed', error)
})
useStore.setState({ startupStatus: 'updating' }) // Resolve + materialize the default config location once, independent of the binary,
// so switching binaries never relocates the user's remotes.
await openSmallWindow({ await ensureDefaultConfig()
name: 'Startup',
url: '/startup',
})
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))
}
const hostState = useHostStore.getState() const hostState = useHostStore.getState()
let configFiles = hostState.configFiles || [] let configFiles = hostState.configFiles || []
console.log('[initRclone] loaded config files count:', configFiles.length) 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') const existingDefaultConfig = configFiles.find((config) => config.id === 'default')
configFiles = configFiles.filter((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') console.log('[initRclone] added default config to list')
useHostStore.setState({ configFiles }) 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) { if (!activeConfigFile) {
console.log('[initRclone] no active config file, setting default') console.log('[initRclone] no active config file, setting default')
activeConfigFile = configFiles[0] activeConfigFile = configFiles[0]
@@ -168,13 +104,7 @@ export async function initRclone(args: string[]) {
} }
console.log('[initRclone] set active config file to:', activeConfigFile.id) console.log('[initRclone] set active config file to:', activeConfigFile.id)
useHostStore.setState({ activeConfigFile }) useHostStore.getState().setActiveConfigFile(activeConfigFile.id!)
}
if (internal && activeConfigFile.id === 'default') {
console.log('[initRclone] creating internal default config file')
const defaultInternalPath = await getConfigPath({ id: 'default', validate: false })
await createConfigFile(defaultInternalPath)
} }
let configFolderPath = activeConfigFile.sync let configFolderPath = activeConfigFile.sync
@@ -203,11 +133,12 @@ export async function initRclone(args: string[]) {
okLabel: 'OK', okLabel: 'OK',
}) })
activeConfigFile = configFiles[0] activeConfigFile = configFiles[0]
configFolderPath = getConfigParentFolder( // Rebind configPath too (not just configFolderPath): otherwise the readTextFile below
await getConfigPath({ id: 'default', validate: true }) // 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') 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 { } else {
console.log('[initRclone] no stored password configured') console.log('[initRclone] no stored password configured')
} }
}
if (!activeConfigFile.isEncrypted) { // Reconcile the stored encryption flag with the file's actual contents. The local rebind
console.log('[initRclone] updating config file encryption flag') // is load-bearing: buildRcloneEnv below reads activeConfigFile to build the password env.
const updatedConfigFile = { ...activeConfigFile, isEncrypted: true } if (activeConfigFile.isEncrypted !== isEncrypted) {
const updatedConfigFiles = configFiles.map((config) => console.log('[initRclone] reconciling encryption flag to', isEncrypted)
config.id === activeConfigFile!.id ? updatedConfigFile : config useHostStore.getState().updateConfigFile(activeConfigFile.id!, { isEncrypted })
) activeConfigFile = { ...activeConfigFile, isEncrypted }
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
} }
} catch (error) { } catch (error) {
console.log('[initRclone] could not read config file', error) console.log('[initRclone] could not read config file', error)
@@ -273,10 +182,7 @@ export async function initRclone(args: string[]) {
return return
} }
const extraParams: { env: Record<string, string> } = { // Proxy connectivity check (informational; the env vars themselves are set by buildRcloneEnv).
env: {},
}
if (hostState.proxy) { if (hostState.proxy) {
console.log('[initRclone] proxy configured:', hostState.proxy.url) console.log('[initRclone] proxy configured:', hostState.proxy.url)
try { try {
@@ -302,301 +208,255 @@ export async function initRclone(args: string[]) {
return 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') { let env: Record<string, string>
console.log('[initRclone] setting custom config path:', configFolderPath) try {
extraParams.env.RCLONE_CONFIG_DIR = configFolderPath env = await buildRcloneEnv({
extraParams.env.RCLONE_CONFIG = `${configFolderPath}${sep()}rclone.conf` activeConfig: activeConfigFile,
} configDirectory: configFolderPath,
configPath,
const commandName = system ? 'rclone-system' : internal ? 'rclone-internal' : null proxy: hostState.proxy,
rclonePath,
if (activeConfigFile.isEncrypted && commandName) { autoPromptForPassword: true,
console.log('[initRclone] ensuring encrypted configuration access') })
try { } catch (error) {
await ensureEncryptedConfigEnv( if (error instanceof Error && error.message === 'Password prompt cancelled by user.') {
activeConfigFile, console.error('[initRclone] password prompt cancelled by user')
extraParams.env, const response = await message('Password is required for encrypted configurations.', {
true, title: 'Password Required',
commandName, kind: 'error',
`Please enter the current password for "${activeConfigFile.label}"` buttons: {
) cancel: 'Close',
} catch (error) { ok: 'Try Again',
if (error instanceof Error && error.message === 'Password prompt cancelled by user.') { },
console.error('[initRclone] password prompt cancelled by user') })
const response = await message( console.log('[initRclone] message response:', response)
'Password is required for encrypted configurations.', if (response === 'Try Again') {
{ await relaunch()
title: 'Password Required',
kind: 'error',
buttons: {
cancel: 'Close',
ok: 'Try Again',
},
}
)
console.log('[initRclone] message response:', response)
if (response === 'Try Again') {
await relaunch()
return
}
await exit(0)
return return
} }
throw error await exit(0)
return
} }
throw error
} }
console.log('[initRclone] extraParams', extraParams) console.log('[initRclone] returning rclone command', { path: rclonePath, args })
return { path: rclonePath, args, env }
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.')
} }
/** /**
* Downloads and provisions the latest version of rclone for the current platform * Resolves the active rclone binary path: validates the persisted selection (self-healing a
* @throws {Error} If architecture detection fails or installation is unsuccessful * managed version whose absolute path moved), otherwise adopts a system / legacy / downloaded
* @returns {Promise<void>} * binary. Returns null when nothing is available so the caller can provision.
*/ */
export async function provisionRclone() { async function resolveActiveRclone(): Promise<string | null> {
console.log('[provisionRclone] starting provisioning process') const persisted = usePersistedStore.getState()
const stored = persisted.rclonePath
console.log('[provisionRclone] fetching latest version info') if (stored) {
const currentVersionString = await fetch('https://downloads.rclone.org/version.txt').then( const version = await validateRcloneBinary(stored)
(res) => res.text() if (version) {
) console.log('[resolveActiveRclone] using stored rclone', stored, version)
console.log('[provisionRclone] currentVersionString', currentVersionString) return stored
}
console.warn('[resolveActiveRclone] stored rclone path is unusable:', stored)
const currentVersion = currentVersionString.split('v')?.[1]?.trim() // Self-heal a managed version whose absolute path moved (e.g. home-dir rename).
const match = stored.match(/rclone-versions[/\\]v([^/\\]+)/)
if (!currentVersion) { if (match) {
console.error('[provisionRclone] failed to get latest version from string') const healed = await invoke<string | null>('managed_version_path', {
await message('Failed to get latest rclone version, please try again later.') version: match[1],
return false
}
console.log('[provisionRclone] currentVersion', currentVersion)
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
try {
tempDirExists = await exists('rclone', {
baseDir: BaseDirectory.Temp,
})
console.log('[provisionRclone] tempDirExists', tempDirExists)
} catch (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') if (healed && (await validateRcloneBinary(healed))) {
} catch (error) { console.log('[resolveActiveRclone] self-healed managed path ->', healed)
Sentry.captureException(error) persisted.setRclonePath(healed)
console.error('[provisionRclone] failed to remove rclone temp dir', error) return healed
await message('Failed to provision rclone.') }
return false
} }
// fall through to the adoption ladder
} }
console.log('[provisionRclone] creating temp directory') // 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 { try {
await mkdir('rclone', { legacyAdopted = await invoke<{ version: string; path: string } | null>(
baseDir: BaseDirectory.Temp, 'adopt_legacy_rclone'
}) )
console.log('[provisionRclone] created rclone temp dir')
} catch (error) { } catch (error) {
Sentry.captureException(error) console.error('[resolveActiveRclone] adopt_legacy_rclone failed', error)
console.error('[provisionRclone] failed to create rclone temp dir', error)
await message('Failed to provision rclone.')
return false
} }
const zipPath = [ // 1. Genuine system rclone — offered, not silently adopted, so the user decides whether the
tempDirPath, // app tracks their system install or manages its own copy. Answering persists a path, so
'rclone', // the question fires only while no usable path is stored.
`rclone-v${currentVersion}-${currentOs}-${arch}.zip`, const system = await findSystemRclone()
].join(sep()) if (system) {
console.log('[provisionRclone] zipPath', zipPath) const systemVersion = await validateRcloneBinary(system)
if (systemVersion) {
console.log('[provisionRclone] writing zip file to disk') const useSystem = await ask(
try { `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.`,
await writeFile(zipPath, new Uint8Array(downloadedFile)) {
console.log('[provisionRclone] wrote zip file successfully') title: 'System rclone detected',
} catch (error) { kind: 'info',
Sentry.captureException(error) okLabel: 'Use system rclone',
console.error('[provisionRclone] failed to write zip file', error) cancelLabel: 'Manage separately',
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
) )
if (useSystem) {
if (attempt < maxCopyRetries) { persisted.setRclonePath(system)
const waitTime = attempt * 1000 return system
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.'
)
} }
} }
} }
console.log('[provisionRclone] verifying installation') // 2. The just-adopted legacy binary. Re-probe it: when the version already existed in the
const hasInstalled = await isInternalRcloneInstalled() // library, adopt_legacy_rclone returns that pre-existing binary without validating it.
console.log('[provisionRclone] installation verified:', hasInstalled) if (legacyAdopted?.path && (await validateRcloneBinary(legacyAdopted.path))) {
persisted.setRclonePath(legacyAdopted.path)
if (!hasInstalled) { return legacyAdopted.path
console.error('[provisionRclone] installation verification failed')
throw new Error('Failed to install rclone')
} }
console.log('[provisionRclone] rclone has been installed successfully') // 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)
}
return true // 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] latest version', version)
let path: string
try {
path = await downloadVersion(version)
} catch (error) {
console.error('[provisionRclone] download failed', error)
Sentry.captureException(error)
await message(
`Failed to download rclone: ${error instanceof Error ? error.message : String(error)}`
)
return false
}
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 { platform } from '@tauri-apps/plugin-os'
import { exit } from '@tauri-apps/plugin-process' import { exit } from '@tauri-apps/plugin-process'
import { usePersistedStore } from '../store/persisted' import { usePersistedStore } from '../store/persisted'
import { CLOSE_APP, emitToMain } from './events'
import { openWindow } from './window' import { openWindow } from './window'
async function buildMenu() { async function buildMenu() {
@@ -136,7 +137,7 @@ async function buildMenu() {
id: 'quit', id: 'quit',
text: 'Quit', text: 'Quit',
action: async () => { action: async () => {
await getCurrentWindow().emit('close-app') await emitToMain(CLOSE_APP)
}, },
}) })
menuItems.push(quitItem) menuItems.push(quitItem)
+1 -1
View File
@@ -11,7 +11,7 @@ export async function openFullWindow({
url: string url: string
hideTitleBar?: boolean hideTitleBar?: boolean
}) { }) {
console.log('[openFullWindow] ', name, url) console.log('[openFullWindow]', name)
await invoke('open_full_window', { name, url, hideTitleBar }) await invoke('open_full_window', { name, url, hideTitleBar })
return WebviewWindow.getByLabel(name) return WebviewWindow.getByLabel(name)
} }
+198 -230
View File
@@ -1,19 +1,19 @@
import * as Sentry from '@sentry/browser' import * as Sentry from '@sentry/browser'
import { getVersion as getUiVersion } from '@tauri-apps/api/app' 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 { getCurrentWindow } from '@tauri-apps/api/window'
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link' import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'
import { ask, message } from '@tauri-apps/plugin-dialog' import { ask, message } from '@tauri-apps/plugin-dialog'
import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log' import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
import { exit, relaunch } from '@tauri-apps/plugin-process' import { exit, relaunch } from '@tauri-apps/plugin-process'
import type { Child } from '@tauri-apps/plugin-shell' import { type Update, check } from '@tauri-apps/plugin-updater'
import { check } from '@tauri-apps/plugin-updater'
import { CronExpressionParser } from 'cron-parser' import { CronExpressionParser } from 'cron-parser'
import { defaultOptions } from 'tauri-plugin-sentry-api' import { defaultOptions } from 'tauri-plugin-sentry-api'
import { getDeepLinkUrl, handleDeepLinkUrl } from './lib/deep' 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 { validateLicense } from './lib/license'
import notify from './lib/notify' import notify from './lib/notify'
import queryClient from './lib/query' import queryClient from './lib/query'
@@ -33,13 +33,35 @@ import { initRclone } from './lib/rclone/init'
import { initTray } from './lib/tray' import { initTray } from './lib/tray'
import { openSmallWindow } from './lib/window' import { openSmallWindow } from './lib/window'
import { initHostStore, useHostStore } from './store/host' import { initHostStore, useHostStore } from './store/host'
import { waitForStoreHydration } from './store/lib'
import { useStore } from './store/memory' import { useStore } from './store/memory'
import { usePersistedStore } from './store/persisted' import { selectCurrentHost, usePersistedStore } from './store/persisted'
import type { ScheduledTask } from './types/schedules' import type { ScheduledTask } from './types/schedules'
let currentRcloneChild: Child | null = null
let rcloneListenersRegistered = false 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 { try {
Sentry.init({ Sentry.init({
...defaultOptions, ...defaultOptions,
@@ -98,19 +120,14 @@ async function checkFlatpakPermissions() {
async function waitForHydration() { async function waitForHydration() {
console.log('[waitForHydration] waiting for store hydration') console.log('[waitForHydration] waiting for store hydration')
await waitForStoreHydration(() => usePersistedStore.persist.hasHydrated())
await new Promise((resolve) => setTimeout(resolve, 50))
if (!usePersistedStore.persist.hasHydrated()) {
await waitForHydration()
}
console.log('[waitForHydration] store hydrated') console.log('[waitForHydration] store hydrated')
} }
async function initializeHostStore() { async function initializeHostStore() {
console.log('[initializeHostStore] initializing') console.log('[initializeHostStore] initializing')
const currentHost = usePersistedStore.getState().currentHost
// Default to 'local' if fresh install/no host selected // 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) await initHostStore(hostId)
@@ -120,7 +137,7 @@ async function initializeHostStore() {
async function checkHostReachability(): Promise<void> { async function checkHostReachability(): Promise<void> {
console.log('[checkHostReachability] checking host reachability') 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 no host selected or local host, skip check (local rclone hasn't started yet)
if (!currentHost || currentHost.id === LOCAL_HOST_ID) { if (!currentHost || currentHost.id === LOCAL_HOST_ID) {
@@ -164,13 +181,15 @@ async function checkHostReachability(): Promise<void> {
console.log('[checkHostReachability] retrying connection') console.log('[checkHostReachability] retrying connection')
isReachable = await checkReachability() isReachable = await checkReachability()
} else { } 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') console.log('[checkHostReachability] switching to local host')
const hosts = usePersistedStore.getState().hosts usePersistedStore.setState((prev) => ({
const localHost = hosts.find((h) => h.id === LOCAL_HOST_ID) hosts: prev.hosts.some((h) => h.id === LOCAL_HOST_ID)
if (localHost) { ? prev.hosts
usePersistedStore.setState({ currentHost: localHost }) : [...prev.hosts, makeLocalHost()],
} currentHostId: LOCAL_HOST_ID,
}))
// Re-initialize host store for local // Re-initialize host store for local
await initHostStore(LOCAL_HOST_ID) await initHostStore(LOCAL_HOST_ID)
return return
@@ -226,8 +245,7 @@ async function checkAlreadyRunning() {
console.log('[checkAlreadyRunning]') console.log('[checkAlreadyRunning]')
try { try {
const rcPort = 5572 const running = await invoke<boolean>('is_rclone_running', { port: RC_PORT })
const running = await invoke<boolean>('is_rclone_running', { port: rcPort })
console.log('[checkAlreadyRunning] running', running) console.log('[checkAlreadyRunning] running', running)
if (running) { if (running) {
@@ -278,24 +296,26 @@ async function registerRcloneWindowListeners() {
const window = getCurrentWindow() const window = getCurrentWindow()
await window.listen('close-app', async () => { // Kill the daemon BEFORE exit/relaunch — this ordering is the entire point of these listeners.
console.log('[registerRcloneWindowListeners] close-app requested') const shutdown = async (mode: 'quit' | 'relaunch') => {
// A dead daemon means "no active transfers": don't let a listTransfers throw make quit a
const transfers = await queryClient.ensureQueryData({ // silent no-op.
queryKey: ['transfers', 'list', 'all'], const transfers = await queryClient
queryFn: async () => await listTransfers(), .ensureQueryData({
staleTime: 10_000, // 10 seconds queryKey: ['transfers', 'list', 'all'],
gcTime: 60_000, // 1 minute queryFn: async () => await listTransfers(),
}) staleTime: 10_000, // 10 seconds
gcTime: 60_000, // 1 minute
})
.catch(() => null)
if (transfers?.active && transfers.active.length > 0) { if (transfers?.active && transfers.active.length > 0) {
const answer = await ask('All active transfers will be stopped.', { const answer = await ask('All active transfers will be stopped.', {
title: 'Exit', title: 'Exit',
kind: 'info', kind: 'info',
okLabel: 'Quit', okLabel: mode === 'relaunch' ? 'Relaunch' : 'Quit',
cancelLabel: 'Cancel', cancelLabel: 'Cancel',
}) })
if (!answer) { if (!answer) {
return return
} }
@@ -304,84 +324,61 @@ async function registerRcloneWindowListeners() {
const cloudflaredTunnel = useStore.getState().cloudflaredTunnel const cloudflaredTunnel = useStore.getState().cloudflaredTunnel
if (cloudflaredTunnel) { if (cloudflaredTunnel) {
try { try {
console.log('[close-app] stopping cloudflared tunnel') console.log('[shutdown] stopping cloudflared tunnel')
await invoke('stop_cloudflared_tunnel', { pid: cloudflaredTunnel.pid }) await invoke('stop_cloudflared_tunnel', { pid: cloudflaredTunnel.pid })
useStore.setState({ cloudflaredTunnel: null }) useStore.setState({ cloudflaredTunnel: null })
} catch (error) { } catch (error) {
console.error('[close-app] failed to stop cloudflared tunnel', error) console.error('[shutdown] failed to stop cloudflared tunnel', error)
} }
} }
const child = currentRcloneChild await killRcloneDaemon()
if (child) { if (mode === 'relaunch') {
try { await relaunch()
await child.kill() } else {
} catch (error) { await exit(0)
console.error('[close-app] failed to kill rclone child', error)
Sentry.captureException(error)
}
currentRcloneChild = null
await new Promise((resolve) => setTimeout(resolve, 1000))
} }
}
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') 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') console.log('[registerRcloneWindowListeners] relaunch-app requested')
await shutdown('relaunch')
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()
}) })
console.log('[registerRcloneWindowListeners] relaunch-app listener registered') 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') 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) { if (useStore.getState().isRestartingRclone) {
console.log('[restart-rclone] restart already in progress, ignoring request') console.log('[restart-rclone] restart already in progress, ignoring request')
return return
@@ -390,18 +387,7 @@ async function registerRcloneWindowListeners() {
useStore.setState({ isRestartingRclone: true }) useStore.setState({ isRestartingRclone: true })
try { try {
const child = currentRcloneChild await killRcloneDaemon()
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 startRclone() await startRclone()
} catch (error) { } catch (error) {
@@ -454,25 +440,28 @@ async function startRclone() {
return await exit(0) return await exit(0)
} }
const command = rclone?.system || rclone?.internal if (!rclone) {
if (!command) {
console.error('[startRclone] initRclone returned without a runnable command') console.error('[startRclone] initRclone returned without a runnable command')
Sentry.captureException(new Error('initRclone returned without a runnable command.')) Sentry.captureException(new Error('initRclone returned without a runnable command.'))
return return
} }
command.addListener('close', async (event) => { const { path, args: rcloneArgs, env } = rclone
console.log('close', event)
currentRcloneChild = null 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') { if (platform() === 'windows') {
return await exit(0) return await exit(0)
} }
console.log('event.code', event.code) if (payload.code === 143 || payload.code === 1) {
if (event.code === 143 || event.code === 1) {
Sentry.captureException(new Error('Rclone has crashed')) Sentry.captureException(new Error('Rclone has crashed'))
const confirmed = await ask('Rclone has crashed', { const confirmed = await ask('Rclone has crashed', {
title: 'Error', title: 'Error',
@@ -485,16 +474,37 @@ async function startRclone() {
} }
await relaunch() await relaunch()
} }
}) }
command.addListener('error', (event) => { console.log('[startRclone] spawning rclone daemon')
console.log('error', event) let pid: number
}) try {
pid = await invoke<number>('spawn_rclone', {
console.log('[startRclone] starting rclone') path,
const childProcess = await command.spawn() args: rcloneArgs,
currentRcloneChild = childProcess env,
console.log('[startRclone] running rclone') onEvent: channel,
})
} 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)) await new Promise((resolve) => setTimeout(resolve, 500))
} }
@@ -593,7 +603,14 @@ async function showStartup() {
} }
console.log('[showStartup] startup not displayed, setting displayed and status') 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') console.log('[showStartup] store updated with startup displayed and status set')
await openSmallWindow({ await openSmallWindow({
name: 'Startup', name: 'Startup',
@@ -617,7 +634,7 @@ async function resumeTasks() {
} }
const scheduledTasks = useHostStore.getState().scheduledTasks 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] found', scheduledTasks.length, 'scheduled tasks')
console.log('[resumeTasks] activeConfigId:', activeConfigId) 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() { async function checkVersion() {
console.log('[checkVersion]') console.log('[checkVersion]')
@@ -939,70 +995,10 @@ async function checkVersion() {
if (compareVersions(currentVersion, minimumVersion) < 0) { if (compareVersions(currentVersion, minimumVersion) < 0) {
console.log('[checkVersion] currentVersion is outdated') console.log('[checkVersion] currentVersion is outdated')
await installUpdate(receivedUpdate, true)
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')
} else if (compareVersions(currentVersion, okVersion) < 0) { } else if (compareVersions(currentVersion, okVersion) < 0) {
console.log('[checkVersion] checking for update') console.log('[checkVersion] checking for update')
await installUpdate(receivedUpdate, false)
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')
} }
} catch (error) { } catch (error) {
console.error('[checkVersion] error', error) console.error('[checkVersion] error', error)
@@ -1011,17 +1007,7 @@ async function checkVersion() {
} }
async function checkRclone() { async function checkRclone() {
let currentHost = usePersistedStore.getState().currentHost let currentHost = selectCurrentHost(usePersistedStore.getState()) ?? makeLocalHost()
if (!currentHost) {
currentHost = {
id: 'local',
name: 'Local Machine',
url: 'http://localhost:5572',
os: 'linux',
cliVersion: 'unknown',
}
}
let hostInfo = await getHostInfo({ let hostInfo = await getHostInfo({
url: currentHost.url, url: currentHost.url,
@@ -1037,13 +1023,7 @@ async function checkRclone() {
kind: 'error', kind: 'error',
} }
) )
currentHost = { currentHost = makeLocalHost()
id: 'local',
name: 'Local Machine',
url: 'http://localhost:5572',
os: 'linux',
cliVersion: 'unknown',
}
hostInfo = await getHostInfo({ hostInfo = await getHostInfo({
url: currentHost.url, url: currentHost.url,
@@ -1078,9 +1058,9 @@ async function checkRclone() {
console.log('[checkRclone] setting currentHost', currentHost) console.log('[checkRclone] setting currentHost', currentHost)
usePersistedStore.setState({ currentHost })
usePersistedStore.setState((prev) => ({ 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() 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) => { onOpenUrl((urls) => {
console.log('deep links while running', urls) console.log('deep links while running', urls)
const receivedUrl = urls[0] processDeepLink(urls[0])
const deepLinkUrl = getDeepLinkUrl(receivedUrl)
console.log('deep link url', deepLinkUrl)
handleDeepLinkUrl(deepLinkUrl)
useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' })
}) })
async function handleDeepLink() { async function handleDeepLink() {
@@ -1110,24 +1088,14 @@ async function handleDeepLink() {
console.log('[handleDeepLink] no deep links found') console.log('[handleDeepLink] no deep links found')
return return
} }
processDeepLink(urls[0])
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')
} }
waitForHydration() waitForHydration()
.then(() => checkFlatpakPermissions()) .then(() => checkFlatpakPermissions())
.then(() => initializeHostStore()) .then(() => initializeHostStore())
.then(() => checkHostReachability()) .then(() => checkHostReachability())
.then(() => registerRcloneWindowListeners())
.then(() => checkVersion()) .then(() => checkVersion())
.then(() => validateInstance()) .then(() => validateInstance())
.then(() => checkAlreadyRunning()) .then(() => checkAlreadyRunning())
-10
View File
@@ -29,7 +29,6 @@
"@tauri-apps/plugin-opener": "^2.5.4", "@tauri-apps/plugin-opener": "^2.5.4",
"@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-store": "^2.4.3", "@tauri-apps/plugin-store": "^2.4.3",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-window-state": "^2.4.1", "@tauri-apps/plugin-window-state": "^2.4.1",
@@ -5984,15 +5983,6 @@
"@tauri-apps/api": "^2.8.0" "@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": { "node_modules/@tauri-apps/plugin-store": {
"version": "2.4.3", "version": "2.4.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.3.tgz", "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-opener": "^2.5.4",
"@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-store": "^2.4.3", "@tauri-apps/plugin-store": "^2.4.3",
"@tauri-apps/plugin-updater": "^2.10.1", "@tauri-apps/plugin-updater": "^2.10.1",
"@tauri-apps/plugin-window-state": "^2.4.1", "@tauri-apps/plugin-window-state": "^2.4.1",
+79 -54
View File
@@ -258,6 +258,7 @@ dependencies = [
"sentry", "sentry",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"sysinfo", "sysinfo",
"tar", "tar",
"tauri", "tauri",
@@ -276,11 +277,12 @@ dependencies = [
"tauri-plugin-prevent-default", "tauri-plugin-prevent-default",
"tauri-plugin-process", "tauri-plugin-process",
"tauri-plugin-sentry", "tauri-plugin-sentry",
"tauri-plugin-shell",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
"tauri-plugin-store", "tauri-plugin-store",
"tauri-plugin-updater", "tauri-plugin-updater",
"tinyfiledialogs-rs", "tinyfiledialogs-rs",
"windows-sys 0.59.0",
"winreg 0.52.0",
"x11rb", "x11rb",
"zbus", "zbus",
"zip 0.6.6", "zip 0.6.6",
@@ -5294,44 +5296,12 @@ dependencies = [
"digest", "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]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 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]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
version = "1.4.8" version = "1.4.8"
@@ -6101,27 +6071,6 @@ dependencies = [
"thiserror 2.0.18", "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]] [[package]]
name = "tauri-plugin-single-instance" name = "tauri-plugin-single-instance"
version = "2.4.2" version = "2.4.2"
@@ -7599,6 +7548,15 @@ dependencies = [
"windows-targets 0.42.2", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@@ -7650,6 +7608,21 @@ dependencies = [
"windows_x86_64_msvc 0.42.2", "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]] [[package]]
name = "windows-targets" name = "windows-targets"
version = "0.52.6" version = "0.52.6"
@@ -7707,6 +7680,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
[[package]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -7725,6 +7704,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
[[package]] [[package]]
name = "windows_aarch64_msvc" name = "windows_aarch64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -7743,6 +7728,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
[[package]] [[package]]
name = "windows_i686_gnu" name = "windows_i686_gnu"
version = "0.52.6" version = "0.52.6"
@@ -7773,6 +7764,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
[[package]] [[package]]
name = "windows_i686_msvc" name = "windows_i686_msvc"
version = "0.52.6" version = "0.52.6"
@@ -7791,6 +7788,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" 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]] [[package]]
name = "windows_x86_64_gnu" name = "windows_x86_64_gnu"
version = "0.52.6" version = "0.52.6"
@@ -7809,6 +7812,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" 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]] [[package]]
name = "windows_x86_64_gnullvm" name = "windows_x86_64_gnullvm"
version = "0.52.6" version = "0.52.6"
@@ -7827,6 +7836,12 @@ version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" 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]] [[package]]
name = "windows_x86_64_msvc" name = "windows_x86_64_msvc"
version = "0.52.6" version = "0.52.6"
@@ -7872,6 +7887,16 @@ dependencies = [
"winapi", "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]] [[package]]
name = "winreg" name = "winreg"
version = "0.55.0" 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", tauri = { version = "2.11.1", features = [ "tray-icon", "image-ico",
"image-png", "config-json5" ] } "image-png", "config-json5" ] }
tauri-plugin-log = "2.8.0" tauri-plugin-log = "2.8.0"
tauri-plugin-shell = "2.3.5"
tauri-plugin-dialog = "2.7.1" tauri-plugin-dialog = "2.7.1"
tauri-plugin-fs = "2.5.1" tauri-plugin-fs = "2.5.1"
tauri-plugin-opener = "2.5.4" 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" tauri-plugin-deep-link = "2.4.9"
flate2 = "1.1.9" flate2 = "1.1.9"
tar = "0.4.45" tar = "0.4.45"
sha2 = "0.10"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
cocoa = "0.26" cocoa = "0.26"
@@ -59,3 +59,7 @@ objc = "0.2"
zbus = { version = "5" } zbus = { version = "5" }
x11rb = "0.13" x11rb = "0.13"
gtk = "0.18" 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-internal-toggle-devtools",
"core:webview:allow-get-all-webviews", "core:webview:allow-get-all-webviews",
"clipboard-manager:allow-write-text", "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", "log:default",
"dialog: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 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 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 _: id = msg_send![ns_window, setBackgroundColor: bg_color];
// let _: () = msg_send![ns_window, setIgnoresMouseEvents:true];
})?; })?;
Ok(()) Ok(())
@@ -155,8 +154,6 @@ pub async fn open_window(
) -> Result<(), String> { ) -> Result<(), String> {
if let Some(existing) = app_handle.get_webview_window(&name) { if let Some(existing) = app_handle.get_webview_window(&name) {
existing.set_focus().map_err(|e| e.to_string())?; existing.set_focus().map_err(|e| e.to_string())?;
#[cfg(target_os = "linux")]
focus_window_linux(&app_handle, &existing);
return Ok(()); return Ok(());
} }
@@ -286,8 +283,6 @@ pub async fn open_small_window(
return Ok(()); return Ok(());
} }
let os = std::env::consts::OS;
let mut builder = WebviewWindowBuilder::new(&app_handle, &name, WebviewUrl::App(url.into())) let mut builder = WebviewWindowBuilder::new(&app_handle, &name, WebviewUrl::App(url.into()))
.title(&name) .title(&name)
.inner_size(800.0, 500.0) .inner_size(800.0, 500.0)
+32 -171
View File
@@ -1,12 +1,10 @@
use machine_uid; use machine_uid;
use sentry; use sentry;
use std::fs::{self, File}; use std::fs;
use std::path::Path;
use sysinfo::System; use sysinfo::System;
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Manager};
use tauri_plugin_sentry; use tauri_plugin_sentry;
use tinyfiledialogs as tfd; use tinyfiledialogs as tfd;
use zip::ZipArchive;
#[path = "../common/shortcut.rs"] #[path = "../common/shortcut.rs"]
mod shortcut; mod shortcut;
@@ -14,6 +12,8 @@ mod shortcut;
#[path = "../common/window.rs"] #[path = "../common/window.rs"]
mod window; mod window;
mod zookeeper;
use shortcut::{ use shortcut::{
ensure_toolbar_window, set_toolbar_shortcut, show_toolbar_window, DEFAULT_TOOLBAR_SHORTCUT, ensure_toolbar_window, set_toolbar_shortcut, show_toolbar_window, DEFAULT_TOOLBAR_SHORTCUT,
}; };
@@ -127,48 +127,7 @@ fn has_flatpak_permissions() -> bool {
false false
} }
#[tauri::command] pub(crate) async fn kill_pid(pid: u32, timeout_ms: Option<u64>) -> Result<(), String> {
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> {
let timeout = timeout_ms.unwrap_or(5000); let timeout = timeout_ms.unwrap_or(5000);
#[cfg(any( #[cfg(any(
@@ -325,7 +284,7 @@ async fn stop_rclone_processes(timeout_ms: Option<u64>) -> Result<u32, String> {
let mut stopped: u32 = 0; let mut stopped: u32 = 0;
for pid in pids { for pid in pids {
match stop_pid(pid, Some(timeout)).await { match kill_pid(pid, Some(timeout)).await {
Ok(()) => stopped += 1, Ok(()) => stopped += 1,
Err(_e) => {} Err(_e) => {}
} }
@@ -334,10 +293,6 @@ async fn stop_rclone_processes(timeout_ms: Option<u64>) -> Result<u32, String> {
Ok(stopped) 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( async fn prompt_text(
title: String, title: String,
message: String, message: String,
@@ -559,6 +514,7 @@ async fn start_cloudflared_tunnel(app: tauri::AppHandle) -> Result<(u32, String)
// Start cloudflared tunnel // Start cloudflared tunnel
let mut child = SysCommand::new(&cloudflared_path) let mut child = SysCommand::new(&cloudflared_path)
// keep in sync with RC_PORT in lib/hosts.ts
.args(&["tunnel", "--url", "http://localhost:5572"]) .args(&["tunnel", "--url", "http://localhost:5572"])
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(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 // 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()) 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; use std::time::Duration;
// Cloudflared takes ~5s to gracefully shut down, so give it enough time // 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(()), Ok(()) => Ok(()),
Err(e) => { Err(e) => {
// Wait a bit for the process to fully terminate // 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())) 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)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
let client = sentry::init(( let client = sentry::init((
@@ -887,6 +730,9 @@ pub fn run() {
} }
let mut app = builder let mut app = builder
.manage::<zookeeper::SharedDaemonState>(std::sync::Mutex::new(
zookeeper::DaemonState::default(),
))
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_sentry::init_with_no_injection(&client)) .plugin(tauri_plugin_sentry::init_with_no_injection(&client))
.plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_clipboard_manager::init())
@@ -903,21 +749,17 @@ pub fn run() {
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_log::Builder::new().build()) .plugin(tauri_plugin_log::Builder::new().build())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_prevent_default::debug()) .plugin(tauri_plugin_prevent_default::debug())
.plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_global_shortcut::Builder::new().build())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
unzip_file,
get_arch, get_arch,
get_uid, get_uid,
is_rclone_running, is_rclone_running,
stop_rclone_processes, stop_rclone_processes,
prompt, prompt,
stop_pid,
update_toolbar_shortcut, update_toolbar_shortcut,
show_toolbar, show_toolbar,
update_system_rclone,
test_proxy_connection, test_proxy_connection,
is_flatpak, is_flatpak,
is_linux_mint, is_linux_mint,
@@ -929,7 +771,22 @@ pub fn run() {
unlock_windows, unlock_windows,
start_cloudflared_tunnel, start_cloudflared_tunnel,
stop_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| { .setup(|app| {
#[cfg(target_os = "linux")] #[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()) { if let Err(err) = ensure_toolbar_window(&app.handle()) {
log::warn!("failed to prepare toolbar window: {}", err); log::warn!("failed to prepare toolbar window: {}", err);
} }
File diff suppressed because it is too large Load Diff
+6 -8
View File
@@ -18,6 +18,7 @@ import { mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import { UploadIcon } from 'lucide-react' import { UploadIcon } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { onErrorDialog } from '../../lib/errors'
import { getConfigPath } from '../../lib/rclone/common' import { getConfigPath } from '../../lib/rclone/common'
import { useHostStore } from '../../store/host' import { useHostStore } from '../../store/host'
import type { ConfigFile } from '../../types/config' import type { ConfigFile } from '../../types/config'
@@ -85,14 +86,11 @@ export default function ConfigCreateDrawer({
onSuccess: () => { onSuccess: () => {
onClose() onClose()
}, },
onError: async (error) => { onError: onErrorDialog('Failed to save config', undefined, {
console.error('[createConfig] failed to save config', error) okLabel: 'OK',
await message(error instanceof Error ? error.message : 'An unknown error occurred', { capture: false,
title: 'Failed to save config', log: ['[createConfig] failed to save config'],
kind: 'error', }),
okLabel: 'OK',
})
},
}) })
return ( return (
+6 -8
View File
@@ -15,6 +15,7 @@ import { message } from '@tauri-apps/plugin-dialog'
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs' import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react' import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
import { onErrorDialog } from '../../lib/errors'
import { getConfigPath } from '../../lib/rclone/common' import { getConfigPath } from '../../lib/rclone/common'
import { useHostStore } from '../../store/host' import { useHostStore } from '../../store/host'
@@ -78,14 +79,11 @@ export default function ConfigEditDrawer({
onSuccess: () => { onSuccess: () => {
onClose() onClose()
}, },
onError: async (error) => { onError: onErrorDialog('Failed to save config', undefined, {
console.error('[updateConfig] failed to save config', error) okLabel: 'OK',
await message(error instanceof Error ? error.message : 'An unknown error occurred', { capture: false,
title: 'Failed to save config', log: ['[updateConfig] failed to save config'],
kind: 'error', }),
okLabel: 'OK',
})
},
}) })
const initializeConfig = useCallback(async () => { const initializeConfig = useCallback(async () => {
+6 -8
View File
@@ -17,6 +17,7 @@ import { exists, readTextFile } from '@tauri-apps/plugin-fs'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import { UploadIcon } from 'lucide-react' import { UploadIcon } from 'lucide-react'
import { useState } from 'react' import { useState } from 'react'
import { onErrorDialog } from '../../lib/errors'
import { useHostStore } from '../../store/host' import { useHostStore } from '../../store/host'
import type { ConfigFile } from '../../types/config' import type { ConfigFile } from '../../types/config'
@@ -70,14 +71,11 @@ export default function ConfigSyncDrawer({
onSuccess: () => { onSuccess: () => {
onClose() onClose()
}, },
onError: async (error) => { onError: onErrorDialog('Failed to save config', undefined, {
console.error('[createSyncConfig] failed to save config', error) okLabel: 'OK',
await message(error instanceof Error ? error.message : 'An unknown error occurred', { capture: false,
title: 'Failed to save config', log: ['[createSyncConfig] failed to save config'],
kind: 'error', }),
okLabel: 'OK',
})
},
}) })
return ( return (
+5 -7
View File
@@ -12,6 +12,7 @@ import { useMutation } from '@tanstack/react-query'
import { message } from '@tauri-apps/plugin-dialog' import { message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import { useState } from 'react' import { useState } from 'react'
import { onErrorDialog } from '../../lib/errors'
import { getHostInfo } from '../../lib/hosts' import { getHostInfo } from '../../lib/hosts'
import { usePersistedStore } from '../../store/persisted' import { usePersistedStore } from '../../store/persisted'
@@ -83,13 +84,10 @@ export default function HostAddDrawer({
setForm(INITIAL_FORM_STATE) setForm(INITIAL_FORM_STATE)
onClose() onClose()
}, },
onError: async (error) => { onError: onErrorDialog('Connection failed', undefined, {
console.error('[addHost] failed', error) capture: false,
await message(error instanceof Error ? error.message : 'An unknown error occurred', { log: ['[addHost] failed'],
title: 'Connection failed', }),
kind: 'error',
})
},
}) })
return ( return (
+7 -1
View File
@@ -541,6 +541,9 @@ export default function OptionsSection({
lineHeight: 20, lineHeight: 20,
paddingTop: 0, paddingTop: 0,
textareaOffsetTop: 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 [activeDecoration, setActiveDecoration] = useState<DecorationState | null>(null)
const [isSelectorOpen, setIsSelectorOpen] = useState(false) const [isSelectorOpen, setIsSelectorOpen] = useState(false)
@@ -576,12 +579,14 @@ export default function OptionsSection({
const baseRect = textareaBaseRef.current?.getBoundingClientRect() const baseRect = textareaBaseRef.current?.getBoundingClientRect()
const textareaRect = textarea.getBoundingClientRect() const textareaRect = textarea.getBoundingClientRect()
const textareaOffsetTop = baseRect ? textareaRect.top - baseRect.top : 0 const textareaOffsetTop = baseRect ? textareaRect.top - baseRect.top : 0
const clientWidth = textarea.clientWidth
setTextareaLayout((previous) => { setTextareaLayout((previous) => {
if ( if (
previous.lineHeight === lineHeight && previous.lineHeight === lineHeight &&
previous.paddingTop === paddingTop && previous.paddingTop === paddingTop &&
previous.textareaOffsetTop === textareaOffsetTop previous.textareaOffsetTop === textareaOffsetTop &&
previous.clientWidth === clientWidth
) { ) {
return previous return previous
} }
@@ -590,6 +595,7 @@ export default function OptionsSection({
lineHeight, lineHeight,
paddingTop, paddingTop,
textareaOffsetTop, 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 { platform } from '@tauri-apps/plugin-os'
import { FolderOpen } from 'lucide-react' import { FolderOpen } from 'lucide-react'
import { startTransition, useCallback, useEffect, useState } from 'react' import { startTransition, useCallback, useEffect, useState } from 'react'
import { onErrorDialog } from '../../lib/errors'
import { useFlags } from '../../lib/hooks' import { useFlags } from '../../lib/hooks'
import { lockWindows, unlockWindows } from '../../lib/window' import { lockWindows, unlockWindows } from '../../lib/window'
import { type RemoteConfig, useHostStore } from '../../store/host' import { type RemoteConfig, useHostStore } from '../../store/host'
@@ -165,13 +166,10 @@ export default function RemoteAutoMountDrawer({
setButtonText('Save Changes') setButtonText('Save Changes')
}, 1200) }, 1200)
}, },
onError: async (error) => { onError: onErrorDialog('Could not update remote', 'Unknown error occurred', {
console.error('Failed to update remote:', error) capture: false,
await message(error instanceof Error ? error.message : 'Unknown error occurred', { log: ['Failed to update remote:'],
title: 'Could not update remote', }),
kind: 'error',
})
},
}) })
const setMountOnStart = useCallback( const setMountOnStart = useCallback(
+1
View File
@@ -109,6 +109,7 @@ export default function RemoteCreateDrawer({
kind: 'error', kind: 'error',
} }
) )
return
} }
await message(errorMessage, { await message(errorMessage, {
+10 -21
View File
@@ -1,10 +1,11 @@
import { Drawer, DrawerBody, DrawerContent, DrawerFooter, DrawerHeader, cn } from '@heroui/react' import { Drawer, DrawerBody, DrawerContent, DrawerFooter, DrawerHeader, cn } from '@heroui/react'
import { Button, Select, SelectItem } from '@heroui/react' import { Button, Select, SelectItem } from '@heroui/react'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { message } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import { ChevronDown, ChevronUp } from 'lucide-react' import { ChevronDown, ChevronUp } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { onErrorDialog } from '../../lib/errors'
import { useRemoteConfig } from '../../lib/hooks'
import queryClient from '../../lib/query' import queryClient from '../../lib/query'
import rclone from '../../lib/rclone/client' import rclone from '../../lib/rclone/client'
import { OVERRIDES } from '../../lib/rclone/overrides' import { OVERRIDES } from '../../lib/rclone/overrides'
@@ -22,18 +23,7 @@ export default function RemoteEditDrawer({
const [config, setConfig] = useState<Record<string, any>>({}) const [config, setConfig] = useState<Record<string, any>>({})
const [showMoreOptions, setShowMoreOptions] = useState(false) const [showMoreOptions, setShowMoreOptions] = useState(false)
const remoteConfigQuery = useQuery({ const remoteConfigQuery = useRemoteConfig(remoteName)
queryKey: ['remote', remoteName, 'config'],
queryFn: async () => {
return await rclone('/config/get', {
params: {
query: {
name: remoteName,
},
},
})
},
})
const remoteConfig = useMemo(() => remoteConfigQuery.data, [remoteConfigQuery.data]) const remoteConfig = useMemo(() => remoteConfigQuery.data, [remoteConfigQuery.data])
@@ -111,7 +101,9 @@ export default function RemoteEditDrawer({
return updatedRemoteConfig return updatedRemoteConfig
}, },
onSuccess: async (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( queryClient.setQueryData(
['remote', remoteName, 'config'], ['remote', remoteName, 'config'],
(old?: typeof remoteConfig) => ({ (old?: typeof remoteConfig) => ({
@@ -121,13 +113,10 @@ export default function RemoteEditDrawer({
) )
onClose() onClose()
}, },
onError: async (error) => { onError: onErrorDialog('Could not update remote', 'Unknown error occurred', {
console.error('Failed to update remote:', error) capture: false,
await message(error instanceof Error ? error.message : 'Unknown error occurred', { log: ['Failed to update remote:'],
title: 'Could not update remote', }),
kind: 'error',
})
},
}) })
// if (!remoteConfig) return null // if (!remoteConfig) return null
+46 -102
View File
@@ -1,9 +1,9 @@
import { Tab, Tabs } from '@heroui/react' import { Tab, Tabs } from '@heroui/react'
import { useQueries, useQuery } from '@tanstack/react-query' 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 { getRemoteName } from '../../lib/format'
import { remoteConfigQueryOptions } from '../../lib/hooks'
import rclone from '../../lib/rclone/client' import rclone from '../../lib/rclone/client'
import type { FlagValue } from '../../types/rclone'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
const IGNORED_OPTIONS = [ const IGNORED_OPTIONS = [
@@ -25,22 +25,23 @@ const IGNORED_OPTIONS = [
'sse_customer_key_md5', '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({ export default function RemoteOptionsSection({
selectedRemotes, selectedRemotes,
remoteOptionsLocked, remoteOptionsLocked,
remoteOptionsJsonString, remoteOptionsJson,
setRemoteOptionsJsonString, setRemoteOptionsJson,
reconcileRemotes,
setRemoteOptionsLocked, setRemoteOptionsLocked,
}: { }: {
selectedRemotes: string[] selectedRemotes: string[]
remoteOptionsLocked: boolean remoteOptionsLocked: boolean
remoteOptionsJsonString: string remoteOptionsJson: Record<string, string>
setRemoteOptionsJsonString: (value: string) => void setRemoteOptionsJson: Dispatch<SetStateAction<Record<string, string>>>
reconcileRemotes: (remoteNames: string[], force?: boolean) => void
setRemoteOptionsLocked: (value: 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({ const backendsQuery = useQuery({
queryKey: ['backends'], queryKey: ['backends'],
queryFn: async () => { queryFn: async () => {
@@ -73,32 +74,16 @@ export default function RemoteOptionsSection({
) )
const remoteConfigQueries = useQueries({ const remoteConfigQueries = useQueries({
queries: uniqueRemotes.map((remote) => ({ queries: uniqueRemotes.map((remote) => remoteConfigQueryOptions(remote)),
queryKey: ['remote', remote, 'config', 'withName'],
queryFn: async () => {
const remoteConfig = await rclone('/config/get', {
params: {
query: {
name: remote,
},
},
})
return {
name: remote,
config: remoteConfig,
}
},
})),
}) })
const remoteConfigs = useMemo( const remoteConfigs = useMemo(
() => () =>
remoteConfigQueries remoteConfigQueries
.map((query) => query.data) .map((query, i) => ({ name: uniqueRemotes[i], config: query.data }))
.map((data) => { .map((data) => {
if (!data) return null
const { config, name } = data const { config, name } = data
if (!config) return null
if (config.type === 's3') { if (config.type === 's3') {
if (config.provider) { if (config.provider) {
@@ -149,81 +134,45 @@ export default function RemoteOptionsSection({
} }
}) })
.filter(Boolean), .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(() => { useEffect(() => {
console.log('[RemoteOptionsSection] optionsJsonStrings', optionsJsonStrings) reconcileRemotes(uniqueRemotes, isFirstReconcile.current)
}, [optionsJsonStrings]) isFirstReconcile.current = false
}, [uniqueRemotes, reconcileRemotes])
useEffect(() => { const tabItems = useMemo(
if (Object.keys(optionsJsonStrings).length === uniqueRemotes.length) { () =>
console.log('[RemoteOptionsSection] optionsJsonStrings already set') remoteConfigs.map((data) => ({
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) => ({
id: data.name, id: data.name,
label: data.name.toUpperCase(), label: data.name.toUpperCase(),
options: data.options, options: data.options,
config: data.config, 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} fullWidth={true}
variant="bordered" variant="bordered"
destroyInactiveTabPanel={false} destroyInactiveTabPanel={false}
@@ -232,13 +181,8 @@ export default function RemoteOptionsSection({
{(item) => ( {(item) => (
<Tab key={item.id} title={item.label}> <Tab key={item.id} title={item.label}>
<OptionsSection <OptionsSection
optionsJson={optionsJsonStrings[item.id]} optionsJson={remoteOptionsJson[item.id] ?? '{}'}
setOptionsJson={(json) => setOptionsJson={setOptionsJsonByRemote[item.id]}
setOptionsJsonStrings((prev) => ({
...prev,
[item.id]: json,
}))
}
globalOptions={item.config} globalOptions={item.config}
availableOptions={item.options} availableOptions={item.options}
isLocked={remoteOptionsLocked} isLocked={remoteOptionsLocked}
+9 -2
View File
@@ -17,6 +17,7 @@ import { format } from 'date-fns'
import { CalendarClockIcon } from 'lucide-react' import { CalendarClockIcon } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { buildReadablePath } from '../../lib/format' import { buildReadablePath } from '../../lib/format'
import { useNow } from '../../lib/hooks'
import { useHostStore } from '../../store/host' import { useHostStore } from '../../store/host'
import type { ScheduledTask } from '../../types/schedules' import type { ScheduledTask } from '../../types/schedules'
import CronEditor from './CronEditor' import CronEditor from './CronEditor'
@@ -51,9 +52,15 @@ export default function ScheduleEditDrawer({
[selectedTask.args] [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(() => { const upcomingRuns = useMemo(() => {
try { try {
const parsed = CronExpressionParser.parse(cronExpression) const parsed = CronExpressionParser.parse(cronExpression, {
currentDate: new Date(now),
})
const runs: Date[] = [] const runs: Date[] = []
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
if (parsed.hasNext()) { if (parsed.hasNext()) {
@@ -64,7 +71,7 @@ export default function ScheduleEditDrawer({
} catch { } catch {
return [] return []
} }
}, [cronExpression]) }, [cronExpression, now])
const hasChanges = useMemo( const hasChanges = useMemo(
() => cronExpression !== selectedTask.cron, () => cronExpression !== selectedTask.cron,
+2 -3
View File
@@ -31,6 +31,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react' import { startTransition, useEffect, useMemo, useState } from 'react'
import { useDebounce } from 'use-debounce' import { useDebounce } from 'use-debounce'
import { formatErrorMessage } from '../../lib/errors'
import { import {
FLAG_CATEGORIES, FLAG_CATEGORIES,
getJsonKeyCount, getJsonKeyCount,
@@ -147,9 +148,7 @@ export default function TemplateAddDrawer({
}, },
onError: async (error) => { onError: async (error) => {
await message( await message(
error instanceof Error formatErrorMessage(error, 'Error saving template. Please check your options.'),
? error.message
: 'Error saving template. Please check your options.',
{ {
title: 'Error', title: 'Error',
kind: 'error', kind: 'error',
+2 -3
View File
@@ -28,6 +28,7 @@ import {
WrenchIcon, WrenchIcon,
} from 'lucide-react' } from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react' import { startTransition, useEffect, useMemo, useState } from 'react'
import { formatErrorMessage } from '../../lib/errors'
import { import {
FLAG_CATEGORIES, FLAG_CATEGORIES,
getJsonKeyCount, getJsonKeyCount,
@@ -170,9 +171,7 @@ export default function TemplateEditDrawer({
}, },
onError: async (error) => { onError: async (error) => {
await message( await message(
error instanceof Error formatErrorMessage(error, 'Error saving template. Please check your options.'),
? error.message
: 'Error saving template. Please check your options.',
{ {
title: 'Error', title: 'Error',
kind: 'error', kind: 'error',
+5 -9
View File
@@ -11,8 +11,8 @@ import {
useRef, useRef,
useState, useState,
} from 'react' } from 'react'
import { remoteConfigQueryOptions } from '../../../lib/hooks'
import { supportsPublicLink } from '../../../lib/rclone/constants' import { supportsPublicLink } from '../../../lib/rclone/constants'
import rclone from '../../../lib/rclone/client'
import { useHostStore } from '../../../store/host.ts' import { useHostStore } from '../../../store/host.ts'
import FileList from './FileList' import FileList from './FileList'
import PanelToolbar, { type ToolbarButtons } from './PanelToolbar' import PanelToolbar, { type ToolbarButtons } from './PanelToolbar'
@@ -90,12 +90,7 @@ const FilePanel = forwardRef<
}) })
const remoteConfigQuery = useQuery({ const remoteConfigQuery = useQuery({
queryKey: ['remote', nav.selectedRemote, 'config'], ...remoteConfigQueryOptions(nav.selectedRemote),
queryFn: async () => {
return await rclone('/config/get', {
params: { query: { name: nav.selectedRemote! } },
})
},
enabled: nav.isRemote, enabled: nav.isRemote,
}) })
@@ -181,12 +176,13 @@ const FilePanel = forwardRef<
} }
}, [onDrop, nav.selectedRemote, nav.cwd]) }, [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(() => { useEffect(() => {
if (onSelectionChange) { if (onSelectionChange) {
onSelectionChange(nav.getSelection()) onSelectionChange(nav.getSelection())
} }
}, [nav.selectedPaths, onSelectionChange, nav.getSelection]) }, [onSelectionChange, nav.getSelection])
useEffect(() => { useEffect(() => {
if (onNavigate && nav.selectedRemote) { if (onNavigate && nav.selectedRemote) {
+2 -12
View File
@@ -1,9 +1,8 @@
import { Button, Input, Tooltip, cn } from '@heroui/react' import { Button, Input, Tooltip, cn } from '@heroui/react'
import { useQuery } from '@tanstack/react-query'
import { platform } from '@tauri-apps/plugin-os' import { platform } from '@tauri-apps/plugin-os'
import { CheckIcon, ChevronRightIcon, LaptopIcon, PencilIcon, StarIcon } from 'lucide-react' import { CheckIcon, ChevronRightIcon, LaptopIcon, PencilIcon, StarIcon } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from '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 type { RemoteString } from './types'
import { getPathSegments } from './utils' import { getPathSegments } from './utils'
@@ -25,16 +24,7 @@ export default function PathBreadcrumb({
const [isInputMode, setIsInputMode] = useState(false) const [isInputMode, setIsInputMode] = useState(false)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
const remoteConfigQuery = useQuery({ const remoteConfigQuery = useRemoteConfig(remote)
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 remoteType = remoteConfigQuery.data?.type const remoteType = remoteConfigQuery.data?.type
+2 -2
View File
@@ -10,7 +10,7 @@ import {
import { DownloadIcon, FileIcon as FileIconLucide, XIcon } from 'lucide-react' import { DownloadIcon, FileIcon as FileIconLucide, XIcon } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { formatBytes } from '../../../lib/format.ts' 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 FileIcon, { getFileType, isPreviewable } from './FileIcon'
import type { Entry } from './types' import type { Entry } from './types'
@@ -44,7 +44,7 @@ export default function PreviewDrawer({
item: Entry | null item: Entry | null
onClose: () => void onClose: () => void
}) { }) {
const currentHost = usePersistedStore((state) => state.currentHost) const currentHost = useCurrentHost()
const hostUrl = currentHost?.url const hostUrl = currentHost?.url
const authUser = currentHost?.authUser const authUser = currentHost?.authUser
const authPassword = currentHost?.authPassword 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 { platform } from '@tauri-apps/plugin-os'
import { StarIcon } from 'lucide-react' import { StarIcon } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useRemoteConfig } from '../../../lib/hooks'
import rclone from '../../../lib/rclone/client.ts' import rclone from '../../../lib/rclone/client.ts'
import type { AllowedKey, RemoteString } from './types' import type { AllowedKey, RemoteString } from './types'
import { getDiskIcon, getDiskLabel, shouldShowDisk } from './utils' import { getDiskIcon, getDiskLabel, shouldShowDisk } from './utils'
@@ -16,18 +17,7 @@ function RemoteButton({
onSelect: (remote: string) => void onSelect: (remote: string) => void
isSelected: boolean isSelected: boolean
}) { }) {
const remoteConfigQuery = useQuery({ const remoteConfigQuery = useRemoteConfig(remote)
queryKey: ['remote', remote, 'config'],
queryFn: async () => {
return await rclone('/config/get', {
params: {
query: {
name: remote,
},
},
})
},
})
const info = remoteConfigQuery.data ?? null 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 { invoke } from '@tauri-apps/api/core'
import { message } from '@tauri-apps/plugin-dialog' import { message } from '@tauri-apps/plugin-dialog'
import { useCallback, useMemo } from 'react' import { useCallback, useMemo } from 'react'
import { reportError } from '../../../lib/errors'
import { getFsInfo } from '../../../lib/format' import { getFsInfo } from '../../../lib/format'
import { useRemoteConfig } from '../../../lib/hooks'
import rclone from '../../../lib/rclone/client' import rclone from '../../../lib/rclone/client'
import { supportsPersistentEmptyFolders } from '../../../lib/rclone/constants' import { supportsPersistentEmptyFolders } from '../../../lib/rclone/constants'
import type { RemoteString } from './types' import type { RemoteString } from './types'
import { RE_TRAILING_SEPARATORS } from './utils' import { RE_TRAILING_SEPARATORS } from './utils'
export default function useCreateFolder( export default function useCreateFolder(remote: RemoteString, cwd: string, refresh: () => void) {
remote: RemoteString, const remoteConfigQuery = useRemoteConfig(remote)
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',
})
const backendType = useMemo(() => { const backendType = useMemo(() => {
if (!remote || remote === 'UI_FAVORITES') return null if (!remote || remote === 'UI_FAVORITES') return null
@@ -74,9 +63,10 @@ export default function useCreateFolder(
refresh() refresh()
} catch (error) { } catch (error) {
await message(error instanceof Error ? error.message : 'Create folder failed', { await reportError(error, {
title: 'Error', title: 'Error',
kind: 'error', fallback: 'Create folder failed',
capture: false,
}) })
} }
}, [remote, cwd, refresh, canCreateFolder]) }, [remote, cwd, refresh, canCreateFolder])
+30 -7
View File
@@ -319,14 +319,26 @@ export default function useFileNavigation({
setRefreshKey((k) => k + 1) setRefreshKey((k) => k + 1)
}, [selectedRemote, cwd]) }, [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(() => { 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 hasInitial = initialRemote !== undefined
const needsLocalPath = initialRemote === 'UI_LOCAL_FS' && !initialPath const needsLocalPath = initialRemote === 'UI_LOCAL_FS' && !initialPath
if (needsLocalPath || (!hasInitial && canShowLocal)) { if (needsLocalPath || (!hasInitial && canShowLocal)) {
hasInitializedRef.current = true
setIsLoading(true) setIsLoading(true)
homeDir().then((home) => { homeDir().then((home) => {
startTransition(() => { startTransition(() => {
@@ -337,12 +349,22 @@ export default function useFileNavigation({
setIsLoading(false) setIsLoading(false)
}) })
} else if (!hasInitial && canShowFavorites) { } else if (!hasInitial && canShowFavorites) {
hasInitializedRef.current = true
startTransition(() => setSelectedRemote('UI_FAVORITES')) startTransition(() => setSelectedRemote('UI_FAVORITES'))
} else if (!hasInitial && canShowRemotes && remotes.length > 0) { } else if (!hasInitial && canShowRemotes) {
startTransition(() => { // remotes still loading (empty list): stay uninitialized so the arrival re-run
setSelectedRemote(remotes[0]) // completes the initialization.
setCwd('') 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, isActive,
@@ -355,6 +377,7 @@ export default function useFileNavigation({
]) ])
// Load directory content when remote/cwd changes // 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(() => { useEffect(() => {
if (!isActive) return 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 // placed here to avoid circular dependency
usePersistedStore.subscribe(async (state, prevState) => { usePersistedStore.subscribe(async (state, prevState) => {
if (state.currentHost?.id !== prevState.currentHost?.id && state.currentHost?.id) { if (state.currentHostId !== prevState.currentHostId && state.currentHostId) {
console.log('[Store] Host changed to', state.currentHost.id) console.log('[Store] Host changed to', state.currentHostId)
await initHostStore(state.currentHost.id).catch(console.error) await initHostStore(state.currentHostId).catch(console.error)
await queryClient.cancelQueries() await queryClient.cancelQueries()
clearClient() clearClient()
queryClient.clear() queryClient.clear()
+267 -516
View File
@@ -1,51 +1,71 @@
import { import { Switch } from '@heroui/react'
Accordion,
AccordionItem,
Avatar,
Button,
ButtonGroup,
Dropdown,
DropdownItem,
DropdownMenu,
DropdownTrigger,
Switch,
Tooltip,
} from '@heroui/react'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core' import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
import { message } from '@tauri-apps/plugin-dialog' import { startTransition, useCallback, useMemo, useState } from 'react'
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 { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags' import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks' import { useFlags } from '../../lib/hooks'
import notify from '../../lib/notify'
import { startBisync } from '../../lib/rclone/api' import { startBisync } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { 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 CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { PathFinder } from '../components/PathFinder' import { PathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection' import RemoteOptionsSection from '../components/RemoteOptionsSection'
import 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() { export default function Bisync() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
@@ -58,167 +78,88 @@ export default function Bisync() {
searchParams.get('initialDestination') ? searchParams.get('initialDestination')! : undefined searchParams.get('initialDestination') ? searchParams.get('initialDestination')! : undefined
) )
const [jsonError, setJsonError] = useState<'bisync' | 'filter' | 'config' | 'remote' | null>( const {
null 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 [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 [cronExpression, setCronExpression] = useState<string | null>(null)
const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest]) 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({ const startBisyncMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
if (!source || !dest) { if (!source || !dest) {
throw new Error('Please select both a source and destination path') throw new Error('Please select both a source and destination path')
} }
return startBisync({ return startBisync(buildStartArgs())
source: source,
destination: dest,
options: {
config: configOptions,
bisync: bisyncOptions,
filter: filterOptions,
remotes: remoteOptions,
outer: outerBisyncOptions,
},
})
}, },
onSuccess: () => { onSuccess: () => {
if (cronExpression) { if (cronExpression) {
scheduleTaskMutation.mutate() scheduleTaskMutation.mutate()
} }
}, },
onError: async (error) => { onError: onErrorDialog('Bisync', 'Failed to start bisync operation', {
console.error('Error starting bisync:', error) capture: false,
const errorMessage = log: ['Error starting bisync:'],
error instanceof Error ? error.message : 'Failed to start bisync operation' }),
await message(errorMessage, {
title: 'Bisync',
kind: 'error',
})
},
}) })
const scheduleTaskMutation = useMutation({ const scheduleTaskMutation = useScheduleTask({
mutationFn: async () => { operation: 'bisync',
cronExpression,
validate: () => {
if (!source || !dest) { if (!source || !dest) {
throw new Error('Please select both a source and destination path') throw new Error('Please select both a source and destination path')
} }
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,
},
},
})
},
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',
})
}, },
buildArgs: buildScheduleArgs,
}) })
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(() => { const buttonText = useMemo(() => {
if (startBisyncMutation.isPending) return 'STARTING...' if (startBisyncMutation.isPending) return 'STARTING...'
if (!source) return 'Please select a source path' if (!source) return 'Please select a source path'
@@ -236,39 +177,14 @@ export default function Bisync() {
return <PlayIcon className="w-5 h-5 fill-current" /> return <PlayIcon className="w-5 h-5 fill-current" />
}, [startBisyncMutation.isPending, source, dest, jsonError]) }, [startBisyncMutation.isPending, source, dest, jsonError])
return ( const accordionItems = useMemo<OptionsAccordionItemDef[]>(
<div className="flex flex-col h-screen gap-10"> () => [
{/* Main Content */} {
<OperationWindowContent> key: 'bisync',
{/* Paths Display */} category: 'bisync',
<PathFinder subtitle: getOptionsSubtitle(Object.keys(bisyncGroup.options).length),
sourcePath={source} children: (
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)}
>
<div className="flex flex-row flex-wrap gap-2 pb-5"> <div className="flex flex-row flex-wrap gap-2 pb-5">
<Switch <Switch
isSelected={outerBisyncOptions?.resync} isSelected={outerBisyncOptions?.resync}
@@ -376,327 +292,162 @@ export default function Bisync() {
</div> </div>
<OptionsSection <OptionsSection
globalOptions={globalFlags?.main || {}} globalOptions={globalFlags?.main || {}}
optionsJson={bisyncOptionsJsonString} optionsJson={bisyncGroup.jsonString}
setOptionsJson={setBisyncOptionsJsonString} setOptionsJson={bisyncGroup.setJsonString}
availableOptions={copyFlags || []} availableOptions={copyFlags || []}
isLocked={bisyncOptionsLocked} isLocked={bisyncGroup.locked}
setIsLocked={setBisyncOptionsLocked} setIsLocked={bisyncGroup.setLocked}
/> />
</AccordionItem> </>
<AccordionItem ),
key="filters" },
startContent={ {
<Avatar color="danger" radius="lg" fallback={<FilterIcon />} /> key: 'filters',
} category: 'filters',
indicator={<FilterIcon />} subtitle: getOptionsSubtitle(Object.keys(filterGroup.options).length),
title="Filters" children: (
subtitle={getOptionsSubtitle(Object.keys(filterOptions).length)} <OptionsSection
> globalOptions={globalFlags?.filter || {}}
<OptionsSection optionsJson={filterGroup.jsonString}
globalOptions={globalFlags?.filter || {}} setOptionsJson={filterGroup.setJsonString}
optionsJson={filterOptionsJsonString} availableOptions={filterFlags || []}
setOptionsJson={setFilterOptionsJsonString} isLocked={filterGroup.locked}
availableOptions={filterFlags || []} setIsLocked={filterGroup.setLocked}
isLocked={filterOptionsLocked} />
setIsLocked={setFilterOptionsLocked} ),
/> },
</AccordionItem> {
<AccordionItem key: 'cron',
key="cron" category: 'cron',
startContent={ children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
<Avatar color="warning" radius="lg" fallback={<ClockIcon />} /> },
} {
indicator={<ClockIcon />} key: 'config',
title="Cron" category: 'config',
> subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length),
<CronEditor expression={cronExpression} onChange={setCronExpression} /> children: (
</AccordionItem> <OptionsSection
<AccordionItem globalOptions={globalFlags?.main || {}}
key="config" optionsJson={configGroup.jsonString}
startContent={ setOptionsJson={configGroup.setJsonString}
<Avatar color="default" radius="lg" fallback={<WrenchIcon />} /> availableOptions={configFlags || []}
} isLocked={configGroup.locked}
indicator={<WrenchIcon />} setIsLocked={configGroup.setLocked}
title="Config" />
subtitle={getOptionsSubtitle(Object.keys(configOptions).length)} ),
> },
<OptionsSection ...(selectedRemotes.length > 0
globalOptions={globalFlags?.main || {}} ? [
optionsJson={configOptionsJsonString} {
setOptionsJson={setConfigOptionsJsonString} key: 'remotes',
availableOptions={configFlags || []} category: 'remotes' as const,
isLocked={configOptionsLocked} subtitle: getOptionsSubtitle(
setIsLocked={setConfigOptionsLocked} Object.values(remotesGroup.options).reduce(
/> (acc, opts) => acc + Object.keys(opts).length,
</AccordionItem> 0
)
),
children: (
<RemoteOptionsSection
selectedRemotes={selectedRemotes}
remoteOptionsJson={remotesGroup.json}
setRemoteOptionsJson={remotesGroup.setJson}
reconcileRemotes={remotesGroup.reconcile}
setRemoteOptionsLocked={remotesGroup.setLocked}
remoteOptionsLocked={remotesGroup.locked}
/>
),
},
]
: []),
],
[
bisyncGroup,
outerBisyncOptions,
globalFlags,
copyFlags,
filterGroup,
filterFlags,
cronExpression,
configGroup,
configFlags,
selectedRemotes,
remotesGroup,
]
)
{selectedRemotes.length > 0 ? ( const handleStart = useCallback(
<AccordionItem () => startBisyncMutation.mutate(),
key={'remotes'} [startBisyncMutation.mutate]
startContent={ )
<Avatar
className="bg-fuchsia-500" const handleSchedule = useCallback(
radius="lg" () => scheduleTaskMutation.mutate(),
fallback={<ServerIcon />} [scheduleTaskMutation.mutate]
/> )
}
indicator={<ServerIcon />} const handleResetPaths = useCallback(() => {
title={'Remotes'} startTransition(() => {
subtitle={getOptionsSubtitle( setSource(undefined)
Object.values(remoteOptions).reduce( setDest(undefined)
(acc, opts) => acc + Object.keys(opts).length, setJsonError(null)
0 startBisyncMutation.reset()
) })
)} }, [setJsonError, startBisyncMutation.reset])
>
<RemoteOptionsSection const handleResetOptions = useCallback(() => {
selectedRemotes={selectedRemotes} startTransition(() => {
remoteOptionsJsonString={remoteOptionsJsonString} resetJson()
setRemoteOptionsJsonString={setRemoteOptionsJsonString} setOuterBisyncOptions({})
setRemoteOptionsLocked={setRemoteOptionsLocked} startBisyncMutation.reset()
remoteOptionsLocked={remoteOptionsLocked} })
/> }, [resetJson, startBisyncMutation.reset])
</AccordionItem>
) : null} const handleResetAll = useCallback(() => {
</Accordion> 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> </OperationWindowContent>
<OperationWindowFooter> <OperationWindowFooter>
<TemplatesDropdown <OperationFooter
isDisabled={!!jsonError}
operation="bisync" operation="bisync"
onSelect={(groupedOptions, shouldMerge) => { templatesDisabled={!!jsonError}
startTransition(() => { onTemplateSelect={applyTemplate}
if (shouldMerge) { getTemplateOptions={getMergedOptions}
if (groupedOptions.copy) startIsSuccess={startBisyncMutation.isSuccess}
setBisyncOptionsJsonString(JSON.stringify({ ...bisyncOptions, ...groupedOptions.copy }, null, 2)) startIsPending={startBisyncMutation.isPending}
if (groupedOptions.filter) onStart={handleStart}
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2)) onSchedule={handleSchedule}
if (groupedOptions.config) startBlocked={!!jsonError || !source || !dest || source === dest}
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2)) buttonText={buttonText}
} else { buttonIcon={buttonIcon}
if (groupedOptions.copy) setBisyncOptionsJsonString(JSON.stringify(groupedOptions.copy, null, 2)) newLabel="NEW BISYNC"
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2)) onResetPaths={handleResetPaths}
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2)) onResetOptions={handleResetOptions}
} onResetAll={handleResetAll}
}) helpContent={HELP_CONTENT}
}}
getOptions={() => ({
...bisyncOptions,
...filterOptions,
...configOptions,
})}
/> />
<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> </OperationWindowFooter>
</div> </div>
) )
+19 -27
View File
@@ -19,7 +19,7 @@ import {
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { writeText } from '@tauri-apps/plugin-clipboard-manager' 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 { platform } from '@tauri-apps/plugin-os'
import { AnimatePresence, motion } from 'framer-motion' import { AnimatePresence, motion } from 'framer-motion'
import { import {
@@ -35,6 +35,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { Group, Panel, Separator } from 'react-resizable-panels' import { Group, Panel, Separator } from 'react-resizable-panels'
import { onErrorDialog, reportError } from '../../lib/errors'
import { getFsInfo } from '../../lib/format' import { getFsInfo } from '../../lib/format'
// import { Document, Page, pdfjs } from 'react-pdf' // import { Document, Page, pdfjs } from 'react-pdf'
import { formatBytes } from '../../lib/format.ts' import { formatBytes } from '../../lib/format.ts'
@@ -123,9 +124,10 @@ export default function Browser() {
const jobId = result?.jobid const jobId = result?.jobid
if (jobId) handleJobStarted(jobId) if (jobId) handleJobStarted(jobId)
} catch (error) { } catch (error) {
await message(error instanceof Error ? error.message : 'Download failed', { await reportError(error, {
title: 'Error', title: 'Error',
kind: 'error', fallback: 'Download failed',
capture: false,
}) })
} }
}, },
@@ -160,9 +162,10 @@ export default function Browser() {
leftPanelRef.current?.refresh() leftPanelRef.current?.refresh()
rightPanelRef.current?.refresh() rightPanelRef.current?.refresh()
} catch (error) { } catch (error) {
await message(error instanceof Error ? error.message : 'Delete failed', { await reportError(error, {
title: 'Error', title: 'Error',
kind: 'error', fallback: 'Delete failed',
capture: false,
}) })
} }
}, []) }, [])
@@ -209,9 +212,10 @@ export default function Browser() {
leftPanelRef.current?.refresh() leftPanelRef.current?.refresh()
rightPanelRef.current?.refresh() rightPanelRef.current?.refresh()
} catch (error) { } catch (error) {
await message(error instanceof Error ? error.message : 'Rename failed', { await reportError(error, {
title: 'Error', title: 'Error',
kind: 'error', fallback: 'Rename failed',
capture: false,
}) })
} }
}, []) }, [])
@@ -235,14 +239,12 @@ export default function Browser() {
}) })
} }
} catch (error) { } catch (error) {
await message( await reportError(error, {
error instanceof Error ? error.message : 'Failed to generate public link', title: 'Share Error',
{ fallback: 'Failed to generate public link',
title: 'Share Error', okLabel: 'OK',
kind: 'error', capture: false,
okLabel: 'OK', })
}
)
} }
}, []) }, [])
@@ -616,12 +618,7 @@ function OperationDialog({
onComplete?.() onComplete?.()
onClose() onClose()
}, },
onError: async (error) => { onError: onErrorDialog('Error', 'Copy operation failed', { capture: false }),
await message(error instanceof Error ? error.message : 'Copy operation failed', {
title: 'Error',
kind: 'error',
})
},
}) })
const moveMutation = useMutation({ const moveMutation = useMutation({
@@ -645,12 +642,7 @@ function OperationDialog({
onComplete?.() onComplete?.()
onClose() onClose()
}, },
onError: async (error) => { onError: onErrorDialog('Error', 'Move operation failed', { capture: false }),
await message(error instanceof Error ? error.message : 'Move operation failed', {
title: 'Error',
kind: 'error',
})
},
}) })
const handleConfirm = useCallback(() => { 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 { useMutation } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core' import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
import { ask, message } from '@tauri-apps/plugin-dialog' import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
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 { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags' import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks' import { useFlags } from '../../lib/hooks'
import notify from '../../lib/notify'
import { startCopy, startDryRun } from '../../lib/rclone/api' import { startCopy, startDryRun } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { openWindow } from '../../lib/window'
import { useHostStore } from '../../store/host'
import { usePersistedStore } from '../../store/persisted' 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 CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { MultiPathFinder } from '../components/PathFinder' import { MultiPathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection' import RemoteOptionsSection from '../components/RemoteOptionsSection'
import ShowMoreOptionsBanner from '../components/ShowMoreOptionsBanner' import OperationFooter from '../components/operation/OperationFooter'
import TemplatesDropdown from '../components/TemplatesDropdown' 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 HELP_CONTENT = `Copies the source(s) to the destination.
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.
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. 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. 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 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}
/> />
<CommandsDropdown currentCommand="copy" /> ),
</ButtonGroup> },
{
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}
/>
</OperationWindowFooter> </OperationWindowFooter>
</div> </div>
) )
+256 -498
View File
@@ -1,511 +1,31 @@
import { import { Alert } from '@heroui/react'
Accordion, import { useMutation } from '@tanstack/react-query'
AccordionItem, import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
Alert, import { startTransition, useCallback, useMemo, useState } from 'react'
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 { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags' import { getOptionsSubtitle } from '../../lib/flags'
import { getRemoteName } from '../../lib/format' import { getRemoteName } from '../../lib/format'
import { useFlags } from '../../lib/hooks' import { useFlags, useRemoteConfig } from '../../lib/hooks'
import notify from '../../lib/notify' import notify from '../../lib/notify'
import { startDelete, startDryRun } from '../../lib/rclone/api' import { startDelete, startDryRun } from '../../lib/rclone/api'
import rclone from '../../lib/rclone/client'
import { RCLONE_CONFIG_DEFAULTS, SUPPORTS_PURGE } from '../../lib/rclone/constants' 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 CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { PathField } from '../components/PathFinder' import { PathField } from '../components/PathFinder'
import 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 PATH_ALLOWED_KEYS: ('LOCAL_FS' | 'FAVORITES' | 'REMOTES')[] = ['REMOTES', 'FAVORITES']
const [searchParams] = useSearchParams()
const { globalFlags, filterFlags, configFlags } = useFlags()
const [sourceFs, setSourceFs] = useState<string | undefined>( const HELP_CONTENT = `Removes files from the specified path.
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.
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. 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. 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 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}
/> />
<CommandsDropdown currentCommand="delete" /> ),
</ButtonGroup> },
{
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}
/>
</OperationWindowFooter> </OperationWindowFooter>
</div> </div>
) )
+6 -8
View File
@@ -8,6 +8,7 @@ import { AlertOctagonIcon, ClockIcon, DownloadIcon, FoldersIcon } from 'lucide-r
import pRetry from 'p-retry' import pRetry from 'p-retry'
import { startTransition, useEffect, useMemo, useState } from 'react' import { startTransition, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import notify from '../../lib/notify' import notify from '../../lib/notify'
import rclone from '../../lib/rclone/client' import rclone from '../../lib/rclone/client'
import CommandInfoButton from '../components/CommandInfoButton' import CommandInfoButton from '../components/CommandInfoButton'
@@ -107,14 +108,11 @@ export default function Download() {
body: 'Download task started', body: 'Download task started',
}) })
}, },
onError: async (error) => { onError: onErrorDialog('Download Error', 'Failed to start download', {
console.error('[Download] Failed to start download', error) okLabel: 'OK',
await message(error instanceof Error ? error.message : 'Failed to start download', { capture: false,
title: 'Download Error', log: ['[Download] Failed to start download'],
kind: 'error', }),
okLabel: 'OK',
})
},
}) })
const buttonText = useMemo(() => { const buttonText = useMemo(() => {
+50 -15
View File
@@ -28,6 +28,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react' import { startTransition, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { reportError } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags' import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks' import { useFlags } from '../../lib/hooks'
import { startMount } from '../../lib/rclone/api' 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.log('[Mount] Mount plugin installed, but failed to start mount')
console.error('Failed to start mount:', error) console.error('Failed to start mount:', error)
await message( await reportError(error, {
error instanceof Error ? error.message : 'Failed to start mount operation', title: 'Mount Error',
{ fallback: 'Failed to start mount operation',
title: 'Mount Error', capture: false,
kind: 'error', })
}
)
}, },
}) })
@@ -308,18 +307,54 @@ export default function Mount() {
startTransition(() => { startTransition(() => {
if (shouldMerge) { if (shouldMerge) {
if (groupedOptions.mount) if (groupedOptions.mount)
setMountOptionsJsonString(JSON.stringify({ ...mountOptions, ...groupedOptions.mount }, null, 2)) setMountOptionsJsonString(
JSON.stringify(
{ ...mountOptions, ...groupedOptions.mount },
null,
2
)
)
if (groupedOptions.vfs) if (groupedOptions.vfs)
setVfsOptionsJsonString(JSON.stringify({ ...vfsOptions, ...groupedOptions.vfs }, null, 2)) setVfsOptionsJsonString(
JSON.stringify(
{ ...vfsOptions, ...groupedOptions.vfs },
null,
2
)
)
if (groupedOptions.filter) if (groupedOptions.filter)
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2)) setFilterOptionsJsonString(
JSON.stringify(
{ ...filterOptions, ...groupedOptions.filter },
null,
2
)
)
if (groupedOptions.config) if (groupedOptions.config)
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2)) setConfigOptionsJsonString(
JSON.stringify(
{ ...configOptions, ...groupedOptions.config },
null,
2
)
)
} else { } else {
if (groupedOptions.mount) setMountOptionsJsonString(JSON.stringify(groupedOptions.mount, null, 2)) if (groupedOptions.mount)
if (groupedOptions.vfs) setVfsOptionsJsonString(JSON.stringify(groupedOptions.vfs, null, 2)) setMountOptionsJsonString(
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2)) JSON.stringify(groupedOptions.mount, null, 2)
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, 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 { useMutation } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core' import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
import { ask, message } from '@tauri-apps/plugin-dialog' import { startTransition, useCallback, useMemo, useState } from 'react'
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 { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags' import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks' import { useFlags } from '../../lib/hooks'
import notify from '../../lib/notify'
import { startDryRun, startMove } from '../../lib/rclone/api' import { startDryRun, startMove } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { openWindow } from '../../lib/window'
import { useHostStore } from '../../store/host'
import { usePersistedStore } from '../../store/persisted' 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 CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { MultiPathFinder } from '../components/PathFinder' import { MultiPathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection' import RemoteOptionsSection from '../components/RemoteOptionsSection'
import ShowMoreOptionsBanner from '../components/ShowMoreOptionsBanner' import OperationFooter from '../components/operation/OperationFooter'
import TemplatesDropdown from '../components/TemplatesDropdown' 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'
/* const HELP_CONTENT = `Moves the source(s) to the destination directory.
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.
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. 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. 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 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}
/> />
<CommandsDropdown currentCommand="move" /> ),
</ButtonGroup> },
{
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}
/>
</OperationWindowFooter> </OperationWindowFooter>
</div> </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 { useMutation } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core' import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
import { message } from '@tauri-apps/plugin-dialog' import { startTransition, useCallback, useMemo, useState } from 'react'
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 { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags' import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks' import { useFlags } from '../../lib/hooks'
import notify from '../../lib/notify'
import { startPurge } from '../../lib/rclone/api' import { startPurge } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { 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 CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { PathField } from '../components/PathFinder' import { PathField } from '../components/PathFinder'
import 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 PATH_ALLOWED_KEYS: ('LOCAL_FS' | 'FAVORITES' | 'REMOTES')[] = ['REMOTES', 'FAVORITES']
const [searchParams] = useSearchParams()
const { globalFlags, configFlags } = useFlags()
const [source, setSource] = useState<string | undefined>( const DEFAULT_EXPANDED_KEYS = ['config', 'cron']
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
)
const [cronExpression, setCronExpression] = useState<string | null>(null) const HELP_CONTENT = `Removes a path and ALL of its contents.
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.
Purge completely deletes the specified directory and everything inside it files, subdirectories, everything. This is a destructive operation that cannot be undone. 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. Tap the folder icon in the bottom bar to load or save option presets.
4. START THE PURGE 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}
/> />
<CommandsDropdown currentCommand="purge" /> ),
</ButtonGroup> },
{
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}
/>
</OperationWindowFooter> </OperationWindowFooter>
</div> </div>
) )
+11 -9
View File
@@ -8,6 +8,7 @@ import { formatDistance } from 'date-fns'
import { AlertCircleIcon, Clock7Icon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react' import { AlertCircleIcon, Clock7Icon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { buildReadablePath } from '../../lib/format' import { buildReadablePath } from '../../lib/format'
import { useNow } from '../../lib/hooks'
import { useHostStore } from '../../store/host' import { useHostStore } from '../../store/host'
import type { ScheduledTask } from '../../types/schedules' import type { ScheduledTask } from '../../types/schedules'
import CommandsDropdown from '../components/CommandsDropdown' import CommandsDropdown from '../components/CommandsDropdown'
@@ -69,10 +70,14 @@ function TaskCard({
} }
}, [task.name, isEditingName]) }, [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 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 return parsed.hasNext() ? parsed.next().toDate() : null
}, [task.cron]) }, [task.cron, now])
const source = useMemo( const source = useMemo(
() => ('source' in task.args ? task.args.source : task.args.sources[0]), () => ('source' in task.args ? task.args.source : task.args.sources[0]),
@@ -81,24 +86,24 @@ function TaskCard({
const nextRunLabel = useMemo(() => { const nextRunLabel = useMemo(() => {
if (nextRun) { 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 distance.charAt(0).toUpperCase() + distance.slice(1)
} }
return 'Never' return 'Never'
}, [nextRun]) }, [nextRun, now])
const lastRunLabel = useMemo(() => { const lastRunLabel = useMemo(() => {
if (task.isRunning) { if (task.isRunning) {
return 'Running now' return 'Running now'
} }
if (task.lastRun) { if (task.lastRun) {
const distance = formatDistance(new Date(task.lastRun), new Date(), { const distance = formatDistance(new Date(task.lastRun), new Date(now), {
addSuffix: true, addSuffix: true,
}) })
return distance.charAt(0).toUpperCase() + distance.slice(1) return distance.charAt(0).toUpperCase() + distance.slice(1)
} }
return 'Never' return 'Never'
}, [task.isRunning, task.lastRun]) }, [task.isRunning, task.lastRun, now])
return ( return (
<Card <Card
@@ -109,9 +114,6 @@ function TaskCard({
onPress={() => onOpenDrawer(task)} onPress={() => onOpenDrawer(task)}
style={{ style={{
flexShrink: 0, flexShrink: 0,
// border: '1px solid #e0e0e070',
// borderBottom: '1px solid #e0e0e070',
// padding: '0.5rem',
}} }}
className="p-2 border-b border-divider" className="p-2 border-b border-divider"
> >
+47 -17
View File
@@ -12,7 +12,6 @@ import {
SelectItem, SelectItem,
Tooltip, Tooltip,
} from '@heroui/react' } from '@heroui/react'
import * as Sentry from '@sentry/browser'
import { useMutation } from '@tanstack/react-query' import { useMutation } from '@tanstack/react-query'
import { message } from '@tauri-apps/plugin-dialog' import { message } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
@@ -30,6 +29,7 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react' import { startTransition, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags' import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks' import { useFlags } from '../../lib/hooks'
import { startServe } from '../../lib/rclone/api' import { startServe } from '../../lib/rclone/api'
@@ -90,14 +90,9 @@ export default function Serve() {
...(vfsOptions as Record<string, FlagValue>), ...(vfsOptions as Record<string, FlagValue>),
}) })
}, },
onError: async (error) => { onError: onErrorDialog('Serve', 'Failed to start serve', {
console.error('[Serve] Failed to start serve:', error) log: ['[Serve] Failed to start serve:'],
Sentry.captureException(error) }),
await message(error instanceof Error ? error.message : 'Failed to start serve', {
title: 'Serve',
kind: 'error',
})
},
}) })
useEffect(() => { useEffect(() => {
@@ -290,19 +285,54 @@ export default function Serve() {
startTransition(() => { startTransition(() => {
if (shouldMerge) { if (shouldMerge) {
if (groupedOptions.serve && type) 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) if (groupedOptions.vfs)
setVfsOptionsJsonString(JSON.stringify({ ...vfsOptions, ...groupedOptions.vfs }, null, 2)) setVfsOptionsJsonString(
JSON.stringify(
{ ...vfsOptions, ...groupedOptions.vfs },
null,
2
)
)
if (groupedOptions.filter) if (groupedOptions.filter)
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2)) setFilterOptionsJsonString(
JSON.stringify(
{ ...filterOptions, ...groupedOptions.filter },
null,
2
)
)
if (groupedOptions.config) if (groupedOptions.config)
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2)) setConfigOptionsJsonString(
JSON.stringify(
{ ...configOptions, ...groupedOptions.config },
null,
2
)
)
} else { } else {
if (groupedOptions.serve && type) if (groupedOptions.serve && type)
setServeOptionsJsonString(JSON.stringify(groupedOptions.serve[type], null, 2)) setServeOptionsJsonString(
if (groupedOptions.vfs) setVfsOptionsJsonString(JSON.stringify(groupedOptions.vfs, null, 2)) JSON.stringify(groupedOptions.serve[type], null, 2)
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2)) )
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, 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 rclone from '../../../lib/rclone/client'
import { getDefaultPaths } from '../../../lib/rclone/common' import { getDefaultPaths } from '../../../lib/rclone/common'
import { DOUBLE_BACKSLASH_REGEX } from '../../../lib/rclone/constants' 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' import BaseSection from './BaseSection'
export default function AboutSection() { export default function AboutSection() {
const currentConfig = useHostStore((state) => state.activeConfigFile) const currentConfig = useHostStore(selectActiveConfigFile)
const rclonePath = usePersistedStore((state) => state.rclonePath)
const defaultPathsQuery = useQuery({ const defaultPathsQuery = useQuery({
queryKey: ['about', 'defaultPaths'], queryKey: ['about', 'defaultPaths'],
@@ -74,6 +76,7 @@ export default function AboutSection() {
}, },
paths: defaultPathsQuery.data, paths: defaultPathsQuery.data,
dirs: dirsQuery.data, dirs: dirsQuery.data,
rcloneBinary: rclonePath,
config: { config: {
id: currentConfig?.id, id: currentConfig?.id,
label: currentConfig?.label, label: currentConfig?.label,
@@ -88,6 +91,7 @@ export default function AboutSection() {
currentConfig, currentConfig,
defaultPathsQuery.data, defaultPathsQuery.data,
dirsQuery.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>
)
}
+86 -83
View File
@@ -28,11 +28,12 @@ import {
Trash2Icon, Trash2Icon,
} from 'lucide-react' } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { onErrorDialog } from '../../../lib/errors'
import { removeConfigPassword, setConfigPassword } from '../../../lib/rclone/api' import { removeConfigPassword, setConfigPassword } from '../../../lib/rclone/api'
import { promptForConfigPassword, restartActiveRclone } from '../../../lib/rclone/cli' import { promptForConfigPassword, restartActiveRclone } from '../../../lib/rclone/cli'
import rclone from '../../../lib/rclone/client' import rclone from '../../../lib/rclone/client'
import { getConfigPath } from '../../../lib/rclone/common' import { getConfigPath } from '../../../lib/rclone/common'
import { useHostStore } from '../../../store/host' import { selectActiveConfigFile, useHostStore } from '../../../store/host'
import { usePersistedStore } from '../../../store/persisted' import { usePersistedStore } from '../../../store/persisted'
import type { ConfigFile } from '../../../types/config' import type { ConfigFile } from '../../../types/config'
import ConfigCreateDrawer from '../../components/ConfigCreateDrawer' import ConfigCreateDrawer from '../../components/ConfigCreateDrawer'
@@ -44,7 +45,7 @@ export default function ConfigSection() {
const licenseValid = usePersistedStore((state) => state.licenseValid) const licenseValid = usePersistedStore((state) => state.licenseValid)
const configFiles = useHostStore((state) => state.configFiles) const configFiles = useHostStore((state) => state.configFiles)
const activeConfigFile = useHostStore((state) => state.activeConfigFile) const activeConfigFile = useHostStore(selectActiveConfigFile)
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -94,14 +95,11 @@ export default function ConfigSection() {
await queryClient.cancelQueries() await queryClient.cancelQueries()
await queryClient.resetQueries() await queryClient.resetQueries()
}, },
onError: async (error) => { onError: onErrorDialog('Switch Config', undefined, {
console.error('[switchConfig] failed to switch config', error) okLabel: 'OK',
await message(error instanceof Error ? error.message : 'An unknown error occurred', { capture: false,
title: 'Switch Config', log: ['[switchConfig] failed to switch config'],
kind: 'error', }),
okLabel: 'OK',
})
},
}) })
const locateConfigMutation = useMutation({ const locateConfigMutation = useMutation({
@@ -109,14 +107,11 @@ export default function ConfigSection() {
const configPath = await getConfigPath({ id: id, validate: true }) const configPath = await getConfigPath({ id: id, validate: true })
await revealItemInDir(configPath) await revealItemInDir(configPath)
}, },
onError: async (error) => { onError: onErrorDialog('Failed to locate config', undefined, {
console.error('[locateConfig] failed to locate config', error) okLabel: 'OK',
await message(error instanceof Error ? error.message : 'An unknown error occurred', { capture: false,
title: 'Failed to locate config', log: ['[locateConfig] failed to locate config'],
kind: 'error', }),
okLabel: 'OK',
})
},
}) })
const exportConfigMutation = useMutation({ const exportConfigMutation = useMutation({
@@ -142,14 +137,11 @@ export default function ConfigSection() {
await writeTextFile(exportPath, text) await writeTextFile(exportPath, text)
}, },
onError: async (error) => { onError: onErrorDialog('Failed to export config', undefined, {
console.error('[exportConfig] failed to export config', error) okLabel: 'OK',
await message(error instanceof Error ? error.message : 'An unknown error occurred', { capture: false,
title: 'Failed to export config', log: ['[exportConfig] failed to export config'],
kind: 'error', }),
okLabel: 'OK',
})
},
}) })
const removePasswordMutation = useMutation({ const removePasswordMutation = useMutation({
@@ -178,14 +170,11 @@ export default function ConfigSection() {
await removeConfigPassword() await removeConfigPassword()
}, },
onError: async (error) => { onError: onErrorDialog('Config Encryption', undefined, {
console.error('[removePassword] failed to remove password', error) okLabel: 'OK',
await message(error instanceof Error ? error.message : 'An unknown error occurred', { capture: false,
title: 'Config Encryption', log: ['[removePassword] failed to remove password'],
kind: 'error', }),
okLabel: 'OK',
})
},
}) })
const setPasswordMutation = useMutation({ const setPasswordMutation = useMutation({
@@ -207,14 +196,11 @@ export default function ConfigSection() {
persist: Boolean(activeConfigFile.pass), persist: Boolean(activeConfigFile.pass),
}) })
}, },
onError: async (error) => { onError: onErrorDialog('Config Encryption', undefined, {
console.error('[setPassword] failed to set password', error) okLabel: 'OK',
await message(error instanceof Error ? error.message : 'An unknown error occurred', { capture: false,
title: 'Config Encryption', log: ['[setPassword] failed to set password'],
kind: 'error', }),
okLabel: 'OK',
})
},
}) })
const savePasswordMutation = useMutation({ const savePasswordMutation = useMutation({
@@ -253,14 +239,11 @@ export default function ConfigSection() {
okLabel: 'OK', okLabel: 'OK',
}) })
}, },
onError: async (error) => { onError: onErrorDialog('Config Password', undefined, {
console.error('[savePasswordCommand] failed to save password command', error) okLabel: 'OK',
await message(error instanceof Error ? error.message : 'An unknown error occurred', { capture: false,
title: 'Config Password', log: ['[savePasswordCommand] failed to save password command'],
kind: 'error', }),
okLabel: 'OK',
})
},
}) })
const savePasswordCommandMutation = useMutation({ const savePasswordCommandMutation = useMutation({
@@ -307,14 +290,11 @@ export default function ConfigSection() {
okLabel: 'OK', okLabel: 'OK',
}) })
}, },
onError: async (error) => { onError: onErrorDialog('Config Password', undefined, {
console.error('[savePasswordCommand] failed to save password command', error) okLabel: 'OK',
await message(error instanceof Error ? error.message : 'An unknown error occurred', { capture: false,
title: 'Config Password', log: ['[savePasswordCommand] failed to save password command'],
kind: 'error', }),
okLabel: 'OK',
})
},
}) })
const removeSavedPasswordMutation = useMutation({ const removeSavedPasswordMutation = useMutation({
@@ -351,14 +331,11 @@ export default function ConfigSection() {
okLabel: 'OK', okLabel: 'OK',
}) })
}, },
onError: async (error) => { onError: onErrorDialog('Config Password', undefined, {
console.error('[removeSavedPassword] failed to remove saved password', error) okLabel: 'OK',
await message(error instanceof Error ? error.message : 'An unknown error occurred', { capture: false,
title: 'Config Password', log: ['[removeSavedPassword] failed to remove saved password'],
kind: 'error', }),
okLabel: 'OK',
})
},
}) })
return ( return (
@@ -515,9 +492,7 @@ function ConfigCard({
const disabled = ['enable'] const disabled = ['enable']
if (configFile.passCommand) { if (configFile.passCommand || configFile.pass) {
disabled.push('save-password', 'save-password-command')
} else if (configFile.pass) {
disabled.push('save-password', 'save-password-command') disabled.push('save-password', 'save-password-command')
} else { } else {
disabled.push('remove-password') disabled.push('remove-password')
@@ -718,22 +693,50 @@ function ConfigCard({
return return
} }
if (!configFile.sync) { try {
const path = await getConfigPath({ if (!configFile.sync) {
id: configFile.id!, // validate: false so a manually-deleted
validate: true, // directory doesn't throw before we can still
}) // clean up the store entry.
const path = await getConfigPath({
id: configFile.id!,
validate: false,
})
await remove(path.replace('rclone.conf', ''), { try {
recursive: true, 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()
.removeConfigFile(configFile.id!)
} catch (error) {
await onErrorDialog('Delete Config')(error)
} }
if (activeConfigFile?.id === configFile.id) {
useHostStore.getState().setActiveConfigFile('default')
}
useHostStore.getState().removeConfigFile(configFile.id!)
}, 100) }, 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 * as Sentry from '@sentry/browser'
import { useMutation, useQuery } from '@tanstack/react-query' import { useMutation, useQuery } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { disable, enable } from '@tauri-apps/plugin-autostart' import { disable, enable } from '@tauri-apps/plugin-autostart'
import { ask, message } from '@tauri-apps/plugin-dialog' import { ask, message } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener' 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 { type Update, check } from '@tauri-apps/plugin-updater'
import { EyeIcon } from 'lucide-react' import { EyeIcon } from 'lucide-react'
import { startTransition, useEffect, useMemo, useState } from 'react' import { startTransition, useEffect, useMemo, useState } from 'react'
import { RELAUNCH_APP, emitToMain } from '../../../lib/events'
import notify from '../../../lib/notify' import notify from '../../../lib/notify'
import { usePersistedStore } from '../../../store/persisted' import { usePersistedStore } from '../../../store/persisted'
import BaseSection from './BaseSection' import BaseSection from './BaseSection'
@@ -130,7 +130,7 @@ export default function GeneralSection() {
return return
} }
await getCurrentWindow().emit('relaunch-app') await emitToMain(RELAUNCH_APP)
}, },
}) })
@@ -234,7 +234,11 @@ export default function GeneralSection() {
label="Tray Theme" label="Tray Theme"
selectedKeys={[appearance.tray]} selectedKeys={[appearance.tray]}
onSelectionChange={(keys) => { 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) => ({ usePersistedStore.setState((state) => ({
appearance: { ...state.appearance, tray: value }, 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 { PlusIcon, RefreshCcwIcon, Trash2Icon } from 'lucide-react'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { type Host, LABEL_FOR_OS, LOCAL_HOST_ID, getHostInfo } from '../../../lib/hosts' 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 HostAddDrawer from '../../components/HostAddDrawer'
import BaseSection from './BaseSection' import BaseSection from './BaseSection'
export default function HostsSection() { export default function HostsSection() {
const hosts = usePersistedStore((state) => state.hosts) const hosts = usePersistedStore((state) => state.hosts)
const currentHost = usePersistedStore((state) => state.currentHost) const currentHost = useCurrentHost()
const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false) const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false)
@@ -89,9 +89,7 @@ function HostCard({
return return
} }
usePersistedStore.setState({ usePersistedStore.getState().setCurrentHost(host.id)
currentHost: host,
})
}, },
onError: () => { onError: () => {
message('Failed to change host. Please try again.', { message('Failed to change host. Please try again.', {
@@ -119,11 +117,6 @@ function HostCard({
usePersistedStore.setState((state) => ({ usePersistedStore.setState((state) => ({
hosts: state.hosts.map((h) => (h.id === host.id ? { ...h, ...hostInfo } : h)), hosts: state.hosts.map((h) => (h.id === host.id ? { ...h, ...hostInfo } : h)),
})) }))
if (isActive) {
usePersistedStore.setState((state) => ({
currentHost: { ...state.currentHost!, ...hostInfo },
}))
}
}, },
onError: () => { onError: () => {
message('Failed to update host. Please try again.', { message('Failed to update host. Please try again.', {
+27 -43
View File
@@ -6,8 +6,8 @@ import {
DropdownItem, DropdownItem,
DropdownMenu, DropdownMenu,
DropdownTrigger, DropdownTrigger,
Spinner,
Input, Input,
Spinner,
} from '@heroui/react' } from '@heroui/react'
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'
import { ask, message } from '@tauri-apps/plugin-dialog' import { ask, message } from '@tauri-apps/plugin-dialog'
@@ -23,7 +23,9 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { type ReactNode, startTransition, useEffect, useMemo, useState } from 'react' import { type ReactNode, startTransition, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../../lib/errors'
import { formatBytes } from '../../../lib/format' import { formatBytes } from '../../../lib/format'
import { remoteConfigQueryOptions } from '../../../lib/hooks'
import rclone from '../../../lib/rclone/client' import rclone from '../../../lib/rclone/client'
import { SUPPORTS_ABOUT } from '../../../lib/rclone/constants' import { SUPPORTS_ABOUT } from '../../../lib/rclone/constants'
import { usePersistedStore } from '../../../store/persisted' import { usePersistedStore } from '../../../store/persisted'
@@ -53,42 +55,38 @@ export default function RemotesSection() {
const remoteConfigQueries = useQueries({ const remoteConfigQueries = useQueries({
queries: remotes.map((remote) => ({ queries: remotes.map((remote) => ({
queryKey: ['remotes', remote, 'config', 'sortable'], ...remoteConfigQueryOptions(remote),
queryFn: async () => {
const config = await rclone('/config/get', {
params: { query: { name: remote } },
})
return { remote, type: config?.type ?? null }
},
staleTime: 1000 * 60, staleTime: 1000 * 60,
})), })),
}) })
const sortedRemotes = useMemo( const sortedRemotes = useMemo(() => {
() => // useQueries preserves input order, so remoteConfigQueries[i] corresponds to remotes[i].
[...remotes].sort((a, b) => { const typeByRemote = new Map<string, string | null>()
const configA = remoteConfigQueries.find((q) => q.data?.remote === a)?.data remotes.forEach((remote, i) => {
const configB = remoteConfigQueries.find((q) => q.data?.remote === b)?.data typeByRemote.set(remote, remoteConfigQueries[i]?.data?.type ?? null)
})
const aSupportsAbout = configA?.type ? SUPPORTS_ABOUT.includes(configA.type) : false return [...remotes].sort((a, b) => {
const bSupportsAbout = configB?.type ? SUPPORTS_ABOUT.includes(configB.type) : false const aType = typeByRemote.get(a)
const bType = typeByRemote.get(b)
if (aSupportsAbout && !bSupportsAbout) return -1 const aSupportsAbout = aType ? SUPPORTS_ABOUT.includes(aType) : false
if (!aSupportsAbout && bSupportsAbout) return 1 const bSupportsAbout = bType ? SUPPORTS_ABOUT.includes(bType) : false
return a.localeCompare(b) if (aSupportsAbout && !bSupportsAbout) return -1
}), if (!aSupportsAbout && bSupportsAbout) return 1
[remotes, remoteConfigQueries]
) return a.localeCompare(b)
})
}, [remotes, remoteConfigQueries])
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const filteredRemotes = useMemo( const filteredRemotes = useMemo(
() => () =>
searchQuery searchQuery
? sortedRemotes.filter((r) => ? sortedRemotes.filter((r) => r.toLowerCase().includes(searchQuery.toLowerCase()))
r.toLowerCase().includes(searchQuery.toLowerCase())
)
: sortedRemotes, : sortedRemotes,
[sortedRemotes, searchQuery] [sortedRemotes, searchQuery]
) )
@@ -116,13 +114,10 @@ export default function RemotesSection() {
...(old ?? []).filter((r) => r !== remote), ...(old ?? []).filter((r) => r !== remote),
]) ])
}, },
onError: async (error) => { onError: onErrorDialog('Could not delete remote', 'Unknown error occurred', {
console.error('Failed to delete remote:', error) capture: false,
await message(error instanceof Error ? error.message : 'Unknown error occurred', { log: ['Failed to delete remote:'],
title: 'Could not delete remote', }),
kind: 'error',
})
},
}) })
const Placeholder = useMemo(() => { const Placeholder = useMemo(() => {
@@ -334,18 +329,7 @@ function RemoteCard({
onConfigPress: () => void onConfigPress: () => void
onDeletePress: () => void onDeletePress: () => void
}) { }) {
const { data: remoteConfigData } = useQuery({ const { data: remoteConfigData } = useQuery(remoteConfigQueryOptions(remote))
queryKey: ['remotes', remote, 'config'],
queryFn: async () => {
return await rclone('/config/get', {
params: {
query: {
name: remote,
},
},
})
},
})
const type = useMemo(() => remoteConfigData?.type ?? null, [remoteConfigData?.type]) const type = useMemo(() => remoteConfigData?.type ?? null, [remoteConfigData?.type])
const provider = useMemo(() => remoteConfigData?.provider ?? null, [remoteConfigData?.provider]) const provider = useMemo(() => remoteConfigData?.provider ?? null, [remoteConfigData?.provider])
+33 -7
View File
@@ -12,6 +12,7 @@ import {
InfoIcon, InfoIcon,
KeyboardIcon, KeyboardIcon,
MedalIcon, MedalIcon,
PackageIcon,
SatelliteDishIcon, SatelliteDishIcon,
ServerIcon, ServerIcon,
TabletSmartphoneIcon, TabletSmartphoneIcon,
@@ -21,8 +22,9 @@ import { useSearchParams } from 'react-router-dom'
import { LOCAL_HOST_ID } from '../../../lib/hosts' import { LOCAL_HOST_ID } from '../../../lib/hosts'
import rclone from '../../../lib/rclone/client' import rclone from '../../../lib/rclone/client'
import { useStore } from '../../../store/memory' import { useStore } from '../../../store/memory'
import { usePersistedStore } from '../../../store/persisted' import { useCurrentHost, usePersistedStore } from '../../../store/persisted'
import AboutSection from './AboutSection' import AboutSection from './AboutSection'
import BinarySection from './BinarySection'
import ConfigSection from './ConfigSection' import ConfigSection from './ConfigSection'
import GeneralSection from './GeneralSection' import GeneralSection from './GeneralSection'
import HostsSection from './HostsSection' import HostsSection from './HostsSection'
@@ -35,7 +37,7 @@ import ToolbarSection from './ToolbarSection'
export default function Settings() { export default function Settings() {
const [searchParams] = useSearchParams() const [searchParams] = useSearchParams()
const settingsPass = usePersistedStore((state) => state.settingsPass) const settingsPass = usePersistedStore((state) => state.settingsPass)
const currentHost = usePersistedStore((state) => state.currentHost) const currentHost = useCurrentHost()
const isRestartingRclone = useStore((state) => state.isRestartingRclone) const isRestartingRclone = useStore((state) => state.isRestartingRclone)
const isLocalHost = useMemo(() => currentHost?.id === LOCAL_HOST_ID, [currentHost?.id]) const isLocalHost = useMemo(() => currentHost?.id === LOCAL_HOST_ID, [currentHost?.id])
@@ -124,11 +126,7 @@ export default function Settings() {
</Button> </Button>
} }
/> />
<Button <Button onPress={checkPassword} data-focus-visible="false" color="primary">
onPress={checkPassword}
data-focus-visible="false"
color="primary"
>
Open Open
</Button> </Button>
</div> </div>
@@ -234,6 +232,34 @@ export default function Settings() {
> >
<ConfigSection /> <ConfigSection />
</Tab> </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 <Tab
key="proxy" key="proxy"
title={ title={
+308 -615
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 { useMutation } from '@tanstack/react-query'
import { invoke } from '@tauri-apps/api/core' import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
import { ask, message } from '@tauri-apps/plugin-dialog' import { startTransition, useCallback, useMemo, useState } from 'react'
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 { useSearchParams } from 'react-router-dom' import { useSearchParams } from 'react-router-dom'
import { onErrorDialog } from '../../lib/errors'
import { getOptionsSubtitle } from '../../lib/flags' import { getOptionsSubtitle } from '../../lib/flags'
import { useFlags } from '../../lib/hooks' import { useFlags } from '../../lib/hooks'
import notify from '../../lib/notify'
import { startDryRun, startSync } from '../../lib/rclone/api' import { startDryRun, startSync } from '../../lib/rclone/api'
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants' import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
import { 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 CronEditor from '../components/CronEditor'
import OperationWindowContent from '../components/OperationWindowContent' import OperationWindowContent from '../components/OperationWindowContent'
import OperationWindowFooter from '../components/OperationWindowFooter' import OperationWindowFooter from '../components/OperationWindowFooter'
import OptionsSection from '../components/OptionsSection' import OptionsSection from '../components/OptionsSection'
import { PathFinder } from '../components/PathFinder' import { PathFinder } from '../components/PathFinder'
import RemoteOptionsSection from '../components/RemoteOptionsSection' import RemoteOptionsSection from '../components/RemoteOptionsSection'
import ShowMoreOptionsBanner from '../components/ShowMoreOptionsBanner' import OperationFooter from '../components/operation/OperationFooter'
import TemplatesDropdown from '../components/TemplatesDropdown' 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 PATH_ALLOWED_KEYS: ('LOCAL_FS' | 'FAVORITES' | 'REMOTES')[] = [
const [searchParams] = useSearchParams() 'LOCAL_FS',
const { globalFlags, filterFlags, configFlags, syncFlags } = useFlags() 'REMOTES',
'FAVORITES',
]
const [source, setSource] = useState<string | undefined>( const SOURCE_OPTIONS = {
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined label: 'Source',
) showPicker: true,
const [dest, setDest] = useState<string | undefined>( placeholder: 'Enter a remote:/path or local path, or tap to select a folder',
searchParams.get('initialDestination') ? searchParams.get('initialDestination')! : undefined clearable: true,
) showFiles: true,
allowedKeys: PATH_ALLOWED_KEYS,
}
const [jsonError, setJsonError] = useState<'sync' | 'filter' | 'config' | 'remote' | null>(null) const DEST_OPTIONS = {
label: 'Destination',
showPicker: true,
placeholder: 'Enter a remote:/path or local path',
clearable: true,
showFiles: false,
allowedKeys: PATH_ALLOWED_KEYS,
}
const [syncOptionsLocked, setSyncOptionsLocked] = useState(false) 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.
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={{
label: 'Source',
showPicker: true,
placeholder:
'Enter a remote:/path or local path, or tap to select a folder',
clearable: true,
showFiles: true,
allowedKeys: ['LOCAL_FS', 'REMOTES', 'FAVORITES'],
}}
destOptions={{
label: 'Destination',
showPicker: true,
placeholder: 'Enter a remote:/path or local path',
clearable: true,
showFiles: false,
allowedKeys: ['LOCAL_FS', 'REMOTES', 'FAVORITES'],
}}
/>
<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.
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. 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. 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 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}
/> />
<CommandsDropdown currentCommand="sync" /> ),
</ButtonGroup> },
{
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}
/>
</OperationWindowFooter> </OperationWindowFooter>
</div> </div>
) )
+43 -40
View File
@@ -1,8 +1,9 @@
import { LazyStore } from '@tauri-apps/plugin-store' import { LazyStore } from '@tauri-apps/plugin-store'
import { create } from 'zustand' 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 { ConfigFile } from '../types/config'
import type { ScheduledTask } from '../types/schedules' import type { ScheduledTask } from '../types/schedules'
import { createTauriStateStorage, waitForStoreHydration } from './lib'
let activeHostId: string | null = null let activeHostId: string | null = null
let activeStore: LazyStore | null = null let activeStore: LazyStore | null = null
@@ -10,15 +11,8 @@ let disposeKeyChange: (() => void) | null = null
export async function initHostStore(hostId: string) { export async function initHostStore(hostId: string) {
if (activeHostId === hostId && activeStore) { if (activeHostId === hostId && activeStore) {
async function waitForHostStoreHydration() { await waitForStoreHydration(() => useHostStore.persist.hasHydrated())
await new Promise((resolve) => setTimeout(resolve, 50)) console.log('[waitForHostStoreHydration] host store hydrated')
if (!useHostStore.persist.hasHydrated()) {
await waitForHostStoreHydration()
}
console.log('[waitForHostStoreHydration] host store hydrated')
}
await waitForHostStoreHydration()
return return
} }
@@ -45,25 +39,6 @@ export async function initHostStore(hostId: string) {
await useHostStore.persist.rehydrate() 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 { export interface RemoteConfig {
mountOnStart?: { mountOnStart?: {
enabled: boolean enabled: boolean
@@ -103,11 +78,16 @@ interface HostState {
configFiles: ConfigFile[] configFiles: ConfigFile[]
addConfigFile: (configFile: ConfigFile) => void addConfigFile: (configFile: ConfigFile) => void
removeConfigFile: (id: string) => void removeConfigFile: (id: string) => void
activeConfigFile: ConfigFile | null activeConfigId: string | null
setActiveConfigFile: (configFile: string) => void setActiveConfigFile: (id: string) => void
updateConfigFile: (id: string, configFile: Partial<ConfigFile>) => void updateConfigFile: (id: string, configFile: Partial<ConfigFile>) => void
lastSkippedVersion: string | undefined 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>()( export const useHostStore = create<HostState>()(
@@ -138,7 +118,7 @@ export const useHostStore = create<HostState>()(
> >
) => { ) => {
const state = get() const state = get()
const configId = state.activeConfigFile?.id const configId = state.activeConfigId
if (!configId) { if (!configId) {
console.error('No active config file for scheduled task') console.error('No active config file for scheduled task')
@@ -178,29 +158,52 @@ export const useHostStore = create<HostState>()(
set((state) => ({ set((state) => ({
configFiles: state.configFiles.filter((f) => f.id !== id), configFiles: state.configFiles.filter((f) => f.id !== id),
})), })),
activeConfigFile: null, activeConfigId: null,
setActiveConfigFile: (id: string) => setActiveConfigFile: (id: string) =>
set((state) => ({ 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>) => updateConfigFile: (id: string, configFile: Partial<ConfigFile>) =>
set((state) => ({ set((state) => ({
configFiles: state.configFiles.map((f) => configFiles: state.configFiles.map((f) =>
f.id === id ? { ...f, ...configFile } : f f.id === id ? { ...f, ...configFile } : f
), ),
activeConfigFile:
state.activeConfigFile?.id === id
? { ...state.activeConfigFile, ...configFile }
: state.activeConfigFile,
})), })),
lastSkippedVersion: undefined, lastSkippedVersion: undefined,
defaultConfigPath: undefined,
setDefaultConfigPath: (path: string | undefined) =>
set((_) => ({ defaultConfigPath: path })),
}), }),
{ {
name: 'host-store', name: 'host-store',
storage: createJSONStorage(getStorage), storage: createJSONStorage(() => createTauriStateStorage(() => activeStore)),
skipHydration: true, 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' import { create } from 'zustand'
interface State { interface State {
firstWindow: boolean
startupStatus: startupStatus:
| null | null
| 'initializing' | 'initializing'
@@ -17,11 +15,6 @@ interface State {
isRestartingRclone: boolean isRestartingRclone: boolean
currentTheme: {
app: 'light' | 'dark' | 'system'
tray: 'light' | 'dark' | 'system'
}
cloudflaredTunnel: { cloudflaredTunnel: {
pid: number pid: number
url: string url: string
@@ -33,18 +26,11 @@ interface State {
export const useStore = create<State>()( export const useStore = create<State>()(
shared( shared(
(_) => ({ (_) => ({
firstWindow: true,
startupStatus: null, startupStatus: null,
startupDisplayed: false, startupDisplayed: false,
isRestartingRclone: false, isRestartingRclone: false,
currentTheme: {
app: 'dark',
tray: 'system',
},
cloudflaredTunnel: null, cloudflaredTunnel: null,
dryRunJobIds: [], dryRunJobIds: [],
+47 -76
View File
@@ -4,13 +4,14 @@ import { platform } from '@tauri-apps/plugin-os'
import { exit } from '@tauri-apps/plugin-process' import { exit } from '@tauri-apps/plugin-process'
import { LazyStore } from '@tauri-apps/plugin-store' import { LazyStore } from '@tauri-apps/plugin-store'
import { create } from 'zustand' 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 { Host } from '../lib/hosts'
import type { SERVE_TYPES } from '../lib/rclone/constants' import type { SERVE_TYPES } from '../lib/rclone/constants'
import type { ConfigFile } from '../types/config' import type { ConfigFile } from '../types/config'
import type { ScheduledTask } from '../types/schedules' import type { ScheduledTask } from '../types/schedules'
import type { Template } from '../types/template' import type { Template } from '../types/template'
import type { RemoteConfig as HostRemoteConfig } from './host' import type { RemoteConfig as HostRemoteConfig } from './host'
import { createTauriStateStorage } from './lib'
const store = new LazyStore('store.json') const store = new LazyStore('store.json')
@@ -33,17 +34,6 @@ interface RemoteConfigV1 {
remoteDefaults?: Record<string, any> 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 { interface TemplateV1 {
id: string id: string
name: string name: string
@@ -53,12 +43,6 @@ interface TemplateV1 {
interface PersistedStateV1 { interface PersistedStateV1 {
remoteConfigList: Record<string, RemoteConfigV1> remoteConfigList: Record<string, RemoteConfigV1>
setRemoteConfig: (remote: string, config: RemoteConfigV1) => void
mergeRemoteConfig: (remote: string, config: RemoteConfigV1) => void
disabledActions: SupportedAction[]
setDisabledActions: (actions: SupportedAction[]) => void
proxy: proxy:
| { | {
@@ -70,34 +54,18 @@ interface PersistedStateV1 {
favoritePaths: { remote: string; path: string; added: number }[] favoritePaths: { remote: string; path: string; added: number }[]
settingsPass: string | undefined settingsPass: string | undefined
setSettingsPass: (pass: string | undefined) => void
licenseKey: string | undefined licenseKey: string | undefined
setLicenseKey: (key: string | undefined) => void
licenseValid: boolean licenseValid: boolean
setLicenseValid: (valid: boolean) => void
startOnBoot: boolean startOnBoot: boolean
setStartOnBoot: (startOnBoot: boolean) => void
scheduledTasks: ScheduledTask[] 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[] templates: TemplateV1[]
configFiles: ConfigFile[] configFiles: ConfigFile[]
addConfigFile: (configFile: ConfigFile) => void
removeConfigFile: (id: string) => void
activeConfigFile: ConfigFile | null activeConfigFile: ConfigFile | null
setActiveConfigFile: (configFile: string) => void
updateConfigFile: (id: string, configFile: Partial<ConfigFile>) => void
lastSkippedVersion: string | undefined lastSkippedVersion: string | undefined
@@ -126,8 +94,7 @@ interface PersistedStateV2 {
templates: Template[] templates: Template[]
hosts: Host[] hosts: Host[]
currentHost: Host | null currentHostId: string | null
updateHost: (id: Host['id'], host: Partial<Host>) => void
setCurrentHost: (id: Host['id']) => void setCurrentHost: (id: Host['id']) => void
hideStartup: boolean hideStartup: boolean
@@ -138,24 +105,19 @@ interface PersistedStateV2 {
tray: 'light' | 'dark' | 'system' | 'color' tray: 'light' | 'dark' | 'system' | 'color'
app: 'light' | 'dark' | 'system' app: 'light' | 'dark' | 'system'
} }
}
const getStorage = (store: LazyStore): StateStorage => ({ // Absolute path of the rclone executable the app runs. Managed downloads live under
getItem: async (name: string): Promise<string | null> => { // $APPLOCALDATA/rclone-versions/vX/, a system rclone is its PATH location, and a custom
console.log('getItem', { name }) // binary is any other path. `undefined` triggers one-time adoption at startup.
return (await store.get(name)) ?? null rclonePath: string | undefined
}, setRclonePath: (path: string | undefined) => void
setItem: async (name: string, value: string): Promise<void> => {
console.log('setItem', { name, value }) // Download + switch to new stable rclone releases at startup (managed binaries only).
await store.set(name, value) // When off, the app still checks and notifies once per new version.
await store.save() autoUpdateRclone: boolean
}, setAutoUpdateRclone: (enabled: boolean) => void
removeItem: async (name: string): Promise<void> => { lastNotifiedRcloneVersion: string | undefined
console.log('removeItem', { name }) }
await store.delete(name)
await store.save()
},
})
export const usePersistedStore = create<PersistedStateV2>()( export const usePersistedStore = create<PersistedStateV2>()(
persist( persist(
@@ -180,32 +142,14 @@ export const usePersistedStore = create<PersistedStateV2>()(
templates: [], templates: [],
hosts: [], hosts: [],
currentHost: null, currentHostId: null,
updateHost: (id: Host['id'], host: Partial<Host>) => setCurrentHost: (id: Host['id']) =>
set((state) => { set((state) => {
if (!state.hosts.some((h) => h.id === id)) { if (!state.hosts.some((h) => h.id === id)) {
return {} return {}
} }
const hosts = state.hosts.map((h) => return { currentHostId: id }
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 }
}), }),
hideStartup: false, hideStartup: false,
@@ -216,11 +160,18 @@ export const usePersistedStore = create<PersistedStateV2>()(
tray: platform() === 'linux' ? 'color' : 'system', tray: platform() === 'linux' ? 'color' : 'system',
app: 'dark', app: 'dark',
}, },
rclonePath: undefined,
setRclonePath: (path: string | undefined) => set((_) => ({ rclonePath: path })),
autoUpdateRclone: true,
setAutoUpdateRclone: (enabled: boolean) => set((_) => ({ autoUpdateRclone: enabled })),
lastNotifiedRcloneVersion: undefined,
}), }),
{ {
name: 'store', name: 'store',
storage: createJSONStorage(() => getStorage(store)), storage: createJSONStorage(() => createTauriStateStorage(() => store)),
version: 2, version: 3,
migrate: async (persistedState, version) => { migrate: async (persistedState, version) => {
if (!persistedState) { if (!persistedState) {
return persistedState as PersistedStateV2 return persistedState as PersistedStateV2
@@ -385,12 +336,32 @@ export const usePersistedStore = create<PersistedStateV2>()(
} as unknown as 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 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) => { usePersistedStore.persist.onFinishHydration((state) => {
if (state.toolbarShortcut) { if (state.toolbarShortcut) {
invoke('update_toolbar_shortcut', { shortcut: state.toolbarShortcut }) invoke('update_toolbar_shortcut', { shortcut: state.toolbarShortcut })
+51 -68
View File
@@ -1,15 +1,16 @@
import { captureException } from '@sentry/browser' import { captureException } from '@sentry/browser'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { writeText } from '@tauri-apps/plugin-clipboard-manager' import { writeText } from '@tauri-apps/plugin-clipboard-manager'
import { ask, message } from '@tauri-apps/plugin-dialog' import { ask, message } from '@tauri-apps/plugin-dialog'
import { openUrl, revealItemInDir } from '@tauri-apps/plugin-opener' 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 notify from '../lib/notify'
import queryClient from '../lib/query' import queryClient from '../lib/query'
import type { fetchMountList, fetchServeList } from '../lib/rclone/api' import type { fetchMountList, fetchServeList } from '../lib/rclone/api'
import rclone from '../lib/rclone/client' import rclone from '../lib/rclone/client'
import { SERVE_TYPES, SUPPORTS_CLEANUP, SUPPORTS_PURGE } from '../lib/rclone/constants' import { SERVE_TYPES, SUPPORTS_CLEANUP, SUPPORTS_PURGE } from '../lib/rclone/constants'
import { openFullWindow } from '../lib/window' 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 { COMMAND_CONFIG, COMMAND_DESCRIPTIONS, COMMAND_KEYWORDS } from './constants'
import type { import type {
ToolbarActionArgs, ToolbarActionArgs,
@@ -272,7 +273,7 @@ const actions: ToolbarActionDefinition[] = [
for (const mount of activeMounts) { for (const mount of activeMounts) {
const mountLabel = formatMountLabel(mount) const mountLabel = formatMountLabel(mount)
if (usePersistedStore.getState().currentHost?.id === 'local') { if (usePersistedStore.getState().currentHostId === 'local') {
results.push( results.push(
createBaseResult( createBaseResult(
`Open ${mountLabel}`, `Open ${mountLabel}`,
@@ -343,14 +344,12 @@ const actions: ToolbarActionDefinition[] = [
try { try {
await revealItemInDir(mountPoint) await revealItemInDir(mountPoint)
} catch (error) { } catch (error) {
console.error('[toolbar] failed to open mount', error) await reportError(error, {
await message( title: 'Open Mount',
error instanceof Error ? error.message : 'Failed to open mount point', fallback: 'Failed to open mount point',
{ capture: false,
title: 'Open Mount', log: ['[toolbar] failed to open mount'],
kind: 'error', })
}
)
} }
return return
} }
@@ -395,14 +394,12 @@ const actions: ToolbarActionDefinition[] = [
old?.filter((m) => m.MountPoint !== mountPoint) ?? [] old?.filter((m) => m.MountPoint !== mountPoint) ?? []
) )
} catch (error) { } catch (error) {
console.error('[toolbar] failed to stop mount', error) await reportError(error, {
await message( title: 'Stop Mount',
error instanceof Error ? error.message : 'Failed to stop mount instance', fallback: 'Failed to stop mount instance',
{ capture: false,
title: 'Stop Mount', log: ['[toolbar] failed to stop mount'],
kind: 'error', })
}
)
await queryClient.resetQueries({ queryKey: ['mount', 'list'] }) await queryClient.resetQueries({ queryKey: ['mount', 'list'] })
} }
return return
@@ -426,16 +423,12 @@ const actions: ToolbarActionDefinition[] = [
}) })
queryClient.setQueryData(['mount', 'list'], []) queryClient.setQueryData(['mount', 'list'], [])
} catch (error) { } catch (error) {
console.error('[toolbar] failed to stop all mounts', error) await reportError(error, {
await message( title: 'Stop All Mounts',
error instanceof Error fallback: 'Failed to stop all mount instances',
? error.message capture: false,
: 'Failed to stop all mount instances', log: ['[toolbar] failed to stop all mounts'],
{ })
title: 'Stop All Mounts',
kind: 'error',
}
)
await queryClient.resetQueries({ queryKey: ['mount', 'list'] }) await queryClient.resetQueries({ queryKey: ['mount', 'list'] })
} }
return return
@@ -570,14 +563,12 @@ const actions: ToolbarActionDefinition[] = [
old?.filter((s) => s.id !== serveId) ?? [] old?.filter((s) => s.id !== serveId) ?? []
) )
} catch (error) { } catch (error) {
console.error('[toolbar] failed to stop serve', error) await reportError(error, {
await message( title: 'Stop Serve',
error instanceof Error ? error.message : 'Failed to stop serve instance', fallback: 'Failed to stop serve instance',
{ capture: false,
title: 'Stop Serve', log: ['[toolbar] failed to stop serve'],
kind: 'error', })
}
)
} }
return return
} }
@@ -591,16 +582,12 @@ const actions: ToolbarActionDefinition[] = [
}) })
queryClient.setQueryData(['serve', 'list'], []) queryClient.setQueryData(['serve', 'list'], [])
} catch (error) { } catch (error) {
console.error('[toolbar] failed to stop all serves', error) await reportError(error, {
await message( title: 'Stop All Serves',
error instanceof Error fallback: 'Failed to stop all serve instances',
? error.message capture: false,
: 'Failed to stop all serve instances', log: ['[toolbar] failed to stop all serves'],
{ })
title: 'Stop All Serves',
kind: 'error',
}
)
await queryClient.resetQueries({ queryKey: ['serve', 'list'] }) await queryClient.resetQueries({ queryKey: ['serve', 'list'] })
} }
return return
@@ -803,8 +790,8 @@ const actions: ToolbarActionDefinition[] = [
return return
} }
const persistedStoreState = usePersistedStore.getState() const currentHost = selectCurrentHost(usePersistedStore.getState())
const hostUrl = persistedStoreState.currentHost?.url const hostUrl = currentHost?.url
if (!hostUrl) { if (!hostUrl) {
await notify({ await notify({
@@ -816,10 +803,10 @@ const actions: ToolbarActionDefinition[] = [
try { try {
let auth: string | undefined let auth: string | undefined
const authUser = persistedStoreState.currentHost?.authUser const authUser = currentHost?.authUser
if (authUser) { if (authUser) {
const authPassword = persistedStoreState.currentHost?.authPassword const authPassword = currentHost?.authPassword
auth = btoa(`${authUser}:${authPassword ?? ''}`) auth = btoa(`${authUser}:${authPassword ?? ''}`)
} }
@@ -1175,7 +1162,7 @@ const actions: ToolbarActionDefinition[] = [
return [] return []
}, },
onPress: async () => { 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) ?? [] (old: string[] | undefined) => old?.filter((v) => v !== fs) ?? []
) )
} catch (error) { } catch (error) {
console.error('[toolbar] failed to forget VFS cache', error) await reportError(error, {
await message( title: 'VFS Forget',
error instanceof Error ? error.message : 'Failed to clear VFS cache', fallback: 'Failed to clear VFS cache',
{ capture: false,
title: 'VFS Forget', log: ['[toolbar] failed to forget VFS cache'],
kind: 'error', })
}
)
} }
return return
} }
@@ -1274,14 +1259,12 @@ const actions: ToolbarActionDefinition[] = [
}) })
queryClient.setQueryData(['vfs', 'list'], []) queryClient.setQueryData(['vfs', 'list'], [])
} catch (error) { } catch (error) {
console.error('[toolbar] failed to forget all VFS caches', error) await reportError(error, {
await message( title: 'VFS Forget All',
error instanceof Error ? error.message : 'Failed to clear all VFS caches', fallback: 'Failed to clear all VFS caches',
{ capture: false,
title: 'VFS Forget All', log: ['[toolbar] failed to forget all VFS caches'],
kind: 'error', })
}
)
} }
return return
} }