zookeeper + cleanup
This commit is contained in:
+1
-1
@@ -55,7 +55,7 @@
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"quoteProperties": "preserve",
|
||||
"trailingComma": "es5",
|
||||
"trailingCommas": "es5",
|
||||
"semicolons": "asNeeded"
|
||||
},
|
||||
"globals": ["it", "describe", "expect", "test"]
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -1,8 +1,42 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { sortByName } from './flags'
|
||||
import rclone from './rclone/client'
|
||||
import { SERVE_TYPES } from './rclone/constants'
|
||||
|
||||
// Wall-clock tick for values derived from "now" (relative timestamps, next cron occurrences).
|
||||
// Memoizing such values without a time dep freezes them at their last dep change. Pass null to
|
||||
// pause (e.g. while a drawer is closed); re-arming refreshes immediately.
|
||||
export function useNow(intervalMs: number | null = 30_000): number {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
if (intervalMs === null) {
|
||||
return
|
||||
}
|
||||
setNow(Date.now())
|
||||
const id = setInterval(() => setNow(Date.now()), intervalMs)
|
||||
return () => clearInterval(id)
|
||||
}, [intervalMs])
|
||||
|
||||
return now
|
||||
}
|
||||
|
||||
// Shared query options for a remote's `/config/get`. No default staleTime: most consumers rely on
|
||||
// staleTime-0 refetch-on-mount for cross-window freshness (each webview has its own QueryClient);
|
||||
// the handful that want caching spread `staleTime` per-site.
|
||||
export function remoteConfigQueryOptions(remote: string | undefined | null) {
|
||||
return {
|
||||
queryKey: ['remote', remote, 'config'] as const,
|
||||
queryFn: () => rclone('/config/get', { params: { query: { name: remote! } } }),
|
||||
enabled: !!remote && remote !== 'UI_LOCAL_FS' && remote !== 'UI_FAVORITES',
|
||||
}
|
||||
}
|
||||
|
||||
export function useRemoteConfig(remote: string | undefined | null) {
|
||||
return useQuery(remoteConfigQueryOptions(remote))
|
||||
}
|
||||
|
||||
export function useFlags() {
|
||||
const allFlagsQuery = useQuery({
|
||||
queryKey: ['options', 'all'],
|
||||
|
||||
+20
-2
@@ -1,4 +1,5 @@
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import pRetry from 'p-retry'
|
||||
import createRCDClient from 'rclone-sdk'
|
||||
|
||||
@@ -14,6 +15,25 @@ export interface Host {
|
||||
|
||||
export const LOCAL_HOST_ID = 'local' as const
|
||||
|
||||
// The rclone RC daemon port. Keep in sync with the hardcoded port in the Rust
|
||||
// start_cloudflared_tunnel command.
|
||||
export const RC_PORT = 5572
|
||||
export const RC_LOCAL_URL = `http://localhost:${RC_PORT}`
|
||||
|
||||
/** The canonical local-machine host, used as the fallback whenever no reachable host is selected. */
|
||||
export function makeLocalHost(): Host {
|
||||
const os = platform()
|
||||
return {
|
||||
id: LOCAL_HOST_ID,
|
||||
name: 'Local Machine',
|
||||
url: RC_LOCAL_URL,
|
||||
// platform() is wider than Host['os'] (ios/android/freebsd/...); desktop builds only see
|
||||
// these three — anything else falls back to linux, mirroring getHostInfo's normalization.
|
||||
os: os === 'windows' || os === 'macos' ? os : 'linux',
|
||||
cliVersion: 'unknown',
|
||||
}
|
||||
}
|
||||
|
||||
export const LABEL_FOR_OS = {
|
||||
windows: 'Windows',
|
||||
macos: 'macOS',
|
||||
@@ -43,8 +63,6 @@ export async function getHostInfo({
|
||||
authHeader = `Basic ${btoa(`${authUser}:${authPassword}`)}`
|
||||
}
|
||||
|
||||
console.log('[getHostInfo] authHeader', authHeader)
|
||||
|
||||
const rcloneClient = createRCDClient({
|
||||
baseUrl: url,
|
||||
headers: authHeader
|
||||
|
||||
+57
-45
@@ -3,44 +3,78 @@ import { fetch } from '@tauri-apps/plugin-http'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { usePersistedStore } from '../store/persisted'
|
||||
|
||||
export async function validateLicense(licenseKey: string) {
|
||||
console.log('[validateLicense]')
|
||||
interface LicenseCallLogs {
|
||||
start: string
|
||||
uidFail: string
|
||||
uidMissing: string
|
||||
fetchFail: string
|
||||
errorResponse: string
|
||||
}
|
||||
|
||||
// Shared scaffold for the license API calls: builds the machine id, POSTs to rcloneui.com, and
|
||||
// runs the error triage. Per-branch log strings are passed in so each caller's logs stay identical.
|
||||
async function licenseCall<T extends { error?: string }>(
|
||||
endpoint: string,
|
||||
licenseKey: string,
|
||||
extraBody: Record<string, unknown>,
|
||||
failVerb: string,
|
||||
logs: LicenseCallLogs
|
||||
): Promise<T> {
|
||||
console.log(logs.start)
|
||||
|
||||
let id
|
||||
|
||||
try {
|
||||
id = await invoke('get_uid')
|
||||
} catch (e) {
|
||||
console.error('[validateLicense] failed to build unique identifier')
|
||||
console.error(logs.uidFail)
|
||||
console.error(JSON.stringify(e))
|
||||
throw new Error('Failed to build unique identifier. Please try again later.')
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
console.error('[validateLicense] missing unique identifier')
|
||||
console.error(logs.uidMissing)
|
||||
throw new Error('Failed to build unique identifier. Please try again later.')
|
||||
}
|
||||
|
||||
const validationResponse = await fetch('https://rcloneui.com/api/v2/validate', {
|
||||
const response = await fetch(`https://rcloneui.com${endpoint}`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
licenseKey,
|
||||
id,
|
||||
platform: platform(),
|
||||
...extraBody,
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json() as Promise<{ error: string; valid: boolean }>)
|
||||
.then((r) => r.json() as Promise<T>)
|
||||
.catch((e) => {
|
||||
console.error('[validateLicense] failed to validate license')
|
||||
console.error(logs.fetchFail)
|
||||
console.error(JSON.stringify(e))
|
||||
throw new Error('Failed to validate license. Are you connected to the internet?')
|
||||
throw new Error(`Failed to ${failVerb} license. Are you connected to the internet?`)
|
||||
})
|
||||
|
||||
if (validationResponse.error) {
|
||||
console.error('[validateLicense] failed to validate license')
|
||||
throw new Error(validationResponse.error)
|
||||
if (response.error) {
|
||||
console.error(logs.errorResponse)
|
||||
throw new Error(response.error)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export async function validateLicense(licenseKey: string) {
|
||||
const validationResponse = await licenseCall<{ error: string; valid: boolean }>(
|
||||
'/api/v2/validate',
|
||||
licenseKey,
|
||||
{ platform: platform() },
|
||||
'validate',
|
||||
{
|
||||
start: '[validateLicense]',
|
||||
uidFail: '[validateLicense] failed to build unique identifier',
|
||||
uidMissing: '[validateLicense] missing unique identifier',
|
||||
fetchFail: '[validateLicense] failed to validate license',
|
||||
errorResponse: '[validateLicense] failed to validate license',
|
||||
}
|
||||
)
|
||||
|
||||
if (!validationResponse.valid) {
|
||||
console.error('[validateLicense] invalid license key')
|
||||
throw new Error('Invalid license key. Please check your license key and try again.')
|
||||
@@ -52,41 +86,19 @@ export async function validateLicense(licenseKey: string) {
|
||||
}
|
||||
|
||||
export async function revokeMachineLicense(licenseKey: string) {
|
||||
console.log('[revokeMachineLicense]')
|
||||
|
||||
let id
|
||||
|
||||
try {
|
||||
id = await invoke('get_uid')
|
||||
} catch (e) {
|
||||
console.error('[revokeMachineLicense] failed to build unique identifier')
|
||||
console.error(JSON.stringify(e))
|
||||
throw new Error('Failed to build unique identifier. Please try again later.')
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
console.error('[revokeMachineLicense] missing unique identifier')
|
||||
throw new Error('Failed to build unique identifier. Please try again later.')
|
||||
}
|
||||
|
||||
const revocationResponse = await fetch('https://rcloneui.com/api/v1/revoke', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
const revocationResponse = await licenseCall<{ error: string; revoked: boolean }>(
|
||||
'/api/v1/revoke',
|
||||
licenseKey,
|
||||
id,
|
||||
}),
|
||||
})
|
||||
.then((r) => r.json() as Promise<{ error: string; revoked: boolean }>)
|
||||
.catch((e) => {
|
||||
console.error('[revokeMachineLicense] failed to revoke license, fetch failed')
|
||||
console.error(JSON.stringify(e))
|
||||
throw new Error('Failed to revoke license. Are you connected to the internet?')
|
||||
})
|
||||
|
||||
if (revocationResponse.error) {
|
||||
console.error('[revokeMachineLicense] failed to revoke license, has error response')
|
||||
throw new Error(revocationResponse.error)
|
||||
{},
|
||||
'revoke',
|
||||
{
|
||||
start: '[revokeMachineLicense]',
|
||||
uidFail: '[revokeMachineLicense] failed to build unique identifier',
|
||||
uidMissing: '[revokeMachineLicense] missing unique identifier',
|
||||
fetchFail: '[revokeMachineLicense] failed to revoke license, fetch failed',
|
||||
errorResponse: '[revokeMachineLicense] failed to revoke license, has error response',
|
||||
}
|
||||
)
|
||||
|
||||
if (!revocationResponse.revoked) {
|
||||
console.error('[revokeMachineLicense] failed to revoke license, missing revoked response')
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
'use no memo'
|
||||
import { persistQueryClient } from '@tanstack/query-persist-client-core'
|
||||
import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
|
||||
+7
-15
@@ -2,7 +2,7 @@ import * as Sentry from '@sentry/browser'
|
||||
import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import pRetry from 'p-retry'
|
||||
import { useHostStore } from '../../store/host'
|
||||
import { selectActiveConfigFile, useHostStore } from '../../store/host'
|
||||
import { useStore } from '../../store/memory'
|
||||
import type { JobItem } from '../../types/jobs'
|
||||
import type { FlagValue } from '../../types/rclone'
|
||||
@@ -108,7 +108,9 @@ function serializeOptions(
|
||||
}
|
||||
|
||||
async function hasStat(path: string) {
|
||||
try {
|
||||
// No try/catch: a transport failure must propagate as the real error instead of being
|
||||
// masked as "Source does not exist". A genuinely missing path returns a response with no
|
||||
// item, which still yields false.
|
||||
const { root, filePath } = getFsInfo(path)
|
||||
const r = await rclone('/operations/stat', {
|
||||
params: {
|
||||
@@ -118,13 +120,7 @@ async function hasStat(path: string) {
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!r || !r.item) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return !!r?.item
|
||||
}
|
||||
|
||||
export async function startCopy({
|
||||
@@ -1280,7 +1276,7 @@ export async function removeConfigPassword() {
|
||||
console.log('[removeConfigPassword]')
|
||||
|
||||
const state = useHostStore.getState()
|
||||
const activeConfig = state.activeConfigFile
|
||||
const activeConfig = selectActiveConfigFile(state)
|
||||
|
||||
if (!activeConfig || !activeConfig.id) {
|
||||
throw new Error('No active configuration selected.')
|
||||
@@ -1317,16 +1313,12 @@ export async function setConfigPassword(options: {
|
||||
console.log('[setConfigPassword]')
|
||||
|
||||
const state = useHostStore.getState()
|
||||
const activeConfig = state.activeConfigFile
|
||||
const activeConfig = selectActiveConfigFile(state)
|
||||
|
||||
if (!activeConfig || !activeConfig.id) {
|
||||
throw new Error('No active configuration selected.')
|
||||
}
|
||||
|
||||
// if (!activeConfig.isEncrypted) {
|
||||
// throw new Error('Configuration is not encrypted.')
|
||||
// }
|
||||
|
||||
const password = options.password
|
||||
|
||||
if (!password) {
|
||||
|
||||
+138
-132
@@ -1,21 +1,27 @@
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { sep } from '@tauri-apps/api/path'
|
||||
import { getAllWindows } from '@tauri-apps/api/window'
|
||||
import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { Command } from '@tauri-apps/plugin-shell'
|
||||
import { useHostStore } from '../../store/host'
|
||||
import { selectActiveConfigFile, useHostStore } from '../../store/host'
|
||||
import { usePersistedStore } from '../../store/persisted'
|
||||
import type { ConfigFile } from '../../types/config'
|
||||
import { RESTART_RCLONE, emitToMain } from '../events'
|
||||
import { getConfigParentFolder } from '../format'
|
||||
import { getConfigPath, isInternalRcloneInstalled, isSystemRcloneInstalled } from './common'
|
||||
import { getConfigPath } from './common'
|
||||
|
||||
interface ExecResult {
|
||||
code: number | null
|
||||
stdout: string
|
||||
stderr: string
|
||||
}
|
||||
|
||||
export interface RcloneCliCommandContext {
|
||||
command: Command<string>
|
||||
rclonePath: string
|
||||
args: string[]
|
||||
activeConfig: ConfigFile
|
||||
configPath: string
|
||||
configDirectory: string
|
||||
env: Record<string, string>
|
||||
flavour: 'system' | 'internal'
|
||||
}
|
||||
|
||||
export async function promptForConfigPassword(message: string) {
|
||||
@@ -42,19 +48,35 @@ export async function promptForConfigPassword(message: string) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the active rclone binary path (set during startup adoption). */
|
||||
export function getActiveRclonePath(): string {
|
||||
const path = usePersistedStore.getState().rclonePath
|
||||
if (!path) {
|
||||
throw new Error('No rclone binary is configured.')
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
async function validateConfigAccess(
|
||||
commandName: 'rclone-system' | 'rclone-internal',
|
||||
env: Record<string, string>
|
||||
rclonePath: string,
|
||||
env: Record<string, string>,
|
||||
timeoutMs: number | null = 15000
|
||||
): Promise<{
|
||||
success: boolean
|
||||
timedOut?: boolean
|
||||
code?: number | null
|
||||
stderr?: string
|
||||
error?: Error
|
||||
}> {
|
||||
console.log('[validateConfigAccess] command:', commandName)
|
||||
console.log('[validateConfigAccess] rclone:', rclonePath)
|
||||
try {
|
||||
const command = Command.create(commandName, ['config', 'dump'], { env })
|
||||
const result = await command.execute()
|
||||
const result = await invoke<ExecResult>('exec_rclone', {
|
||||
path: rclonePath,
|
||||
args: ['config', 'dump'],
|
||||
env,
|
||||
stdinLines: null,
|
||||
timeoutMs,
|
||||
})
|
||||
console.log('[validateConfigAccess] exit code:', result.code)
|
||||
|
||||
if (result.code === 0) {
|
||||
@@ -63,6 +85,9 @@ async function validateConfigAccess(
|
||||
|
||||
return {
|
||||
success: false,
|
||||
// A null code means the probe was killed at the deadline — rclone never ruled on the
|
||||
// credentials, so callers must not treat this as a wrong password.
|
||||
timedOut: result.code === null,
|
||||
code: result.code ?? null,
|
||||
stderr: result.stderr,
|
||||
}
|
||||
@@ -79,7 +104,7 @@ export async function ensureEncryptedConfigEnv(
|
||||
activeConfig: ConfigFile,
|
||||
env: Record<string, string>,
|
||||
autoPromptForPassword: boolean,
|
||||
commandName: 'rclone-system' | 'rclone-internal',
|
||||
rclonePath: string,
|
||||
promptMessage: string
|
||||
) {
|
||||
console.log('[ensureEncryptedConfigEnv] ensuring encrypted config env for:', activeConfig.id)
|
||||
@@ -93,7 +118,9 @@ export async function ensureEncryptedConfigEnv(
|
||||
RCLONE_CONFIG_PASS_COMMAND: activeConfig.passCommand,
|
||||
}
|
||||
|
||||
const validation = await validateConfigAccess(commandName, validationEnv)
|
||||
// Password commands can block on user interaction (biometric prompt, pinentry), so this
|
||||
// probe must not have a deadline.
|
||||
const validation = await validateConfigAccess(rclonePath, validationEnv, null)
|
||||
if (validation.success) {
|
||||
console.log('[ensureEncryptedConfigEnv] passCommand validation succeeded')
|
||||
env.RCLONE_CONFIG_PASS_COMMAND = activeConfig.passCommand
|
||||
@@ -146,7 +173,7 @@ export async function ensureEncryptedConfigEnv(
|
||||
RCLONE_CONFIG_PASS: password,
|
||||
}
|
||||
|
||||
const validation = await validateConfigAccess(commandName, validationEnv)
|
||||
const validation = await validateConfigAccess(rclonePath, validationEnv)
|
||||
if (validation.success) {
|
||||
console.log('[ensureEncryptedConfigEnv] password validation succeeded')
|
||||
env.RCLONE_CONFIG_PASS = password
|
||||
@@ -155,6 +182,16 @@ export async function ensureEncryptedConfigEnv(
|
||||
|
||||
console.error('[ensureEncryptedConfigEnv] password validation failed', validation.code)
|
||||
|
||||
if (validation.timedOut || validation.error) {
|
||||
// Indeterminate result (probe killed at its deadline, or rclone failed to launch) —
|
||||
// the password may well be correct, so never clear a stored one or reprompt over it.
|
||||
throw new Error(
|
||||
validation.error
|
||||
? `Could not verify the configuration password: ${validation.error.message}`
|
||||
: 'Timed out while verifying the configuration password. Please try again.'
|
||||
)
|
||||
}
|
||||
|
||||
if (passwordSource === 'stored') {
|
||||
console.log('[ensureEncryptedConfigEnv] clearing invalid stored password')
|
||||
if (activeConfigId && updateConfigFile) {
|
||||
@@ -207,21 +244,68 @@ export async function ensureEncryptedConfigEnv(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the environment map for running rclone: proxy vars, config location (always set to the
|
||||
* resolved config path), and encrypted-config credentials. Shared by the daemon and one-off CLI.
|
||||
*/
|
||||
export async function buildRcloneEnv(opts: {
|
||||
activeConfig: ConfigFile
|
||||
configDirectory: string
|
||||
configPath: string
|
||||
proxy?: { url: string; ignoredHosts: string[] } | undefined
|
||||
rclonePath: string
|
||||
autoPromptForPassword?: boolean
|
||||
additionalEnv?: Record<string, string>
|
||||
}): Promise<Record<string, string>> {
|
||||
const env: Record<string, string> = {}
|
||||
|
||||
if (opts.proxy?.url) {
|
||||
env.http_proxy = opts.proxy.url
|
||||
env.https_proxy = opts.proxy.url
|
||||
env.HTTP_PROXY = opts.proxy.url
|
||||
env.HTTPS_PROXY = opts.proxy.url
|
||||
env.no_proxy = opts.proxy.ignoredHosts.join(',')
|
||||
env.NO_PROXY = opts.proxy.ignoredHosts.join(',')
|
||||
}
|
||||
|
||||
// Always pin the config location. For a system + default-config user this equals rclone's own
|
||||
// default (explicit = default), and for managed/custom it prevents falling back to a wrong path.
|
||||
env.RCLONE_CONFIG_DIR = opts.configDirectory
|
||||
env.RCLONE_CONFIG = opts.configPath.endsWith('rclone.conf')
|
||||
? opts.configPath
|
||||
: `${opts.configDirectory}${sep()}rclone.conf`
|
||||
|
||||
if (opts.activeConfig.isEncrypted) {
|
||||
await ensureEncryptedConfigEnv(
|
||||
opts.activeConfig,
|
||||
env,
|
||||
opts.autoPromptForPassword ?? true,
|
||||
opts.rclonePath,
|
||||
`Please enter the current password for "${opts.activeConfig.label}"`
|
||||
)
|
||||
}
|
||||
|
||||
if (opts.additionalEnv) {
|
||||
Object.assign(env, opts.additionalEnv)
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
async function createRcloneCliCommand(
|
||||
args: string[],
|
||||
additionalEnv?: Record<string, string>,
|
||||
autoPromptForPassword = true
|
||||
): Promise<RcloneCliCommandContext> {
|
||||
console.log('[createRcloneCliCommand] creating rclone CLI command with args:', args)
|
||||
const env: Record<string, string> = {}
|
||||
const hostStore = useHostStore.getState()
|
||||
const activeConfig = hostStore.activeConfigFile
|
||||
const activeConfig = selectActiveConfigFile(hostStore)
|
||||
|
||||
if (!activeConfig || !activeConfig.id) {
|
||||
throw new Error('No active configuration selected.')
|
||||
}
|
||||
|
||||
console.log('[createRcloneCliCommand] active config:', activeConfig)
|
||||
const rclonePath = getActiveRclonePath()
|
||||
|
||||
let configPath: string
|
||||
try {
|
||||
@@ -231,150 +315,72 @@ async function createRcloneCliCommand(
|
||||
throw error
|
||||
}
|
||||
|
||||
console.log('[createRcloneCliCommand] config path:', configPath)
|
||||
|
||||
const configDirectory = getConfigParentFolder(configPath)
|
||||
console.log('[createRcloneCliCommand] config directory:', configDirectory)
|
||||
|
||||
const proxy = hostStore.proxy
|
||||
console.log('[createRcloneCliCommand] proxy:', proxy)
|
||||
if (proxy?.url) {
|
||||
env.http_proxy = proxy.url
|
||||
env.https_proxy = proxy.url
|
||||
env.HTTP_PROXY = proxy.url
|
||||
env.HTTPS_PROXY = proxy.url
|
||||
env.no_proxy = proxy.ignoredHosts.join(',')
|
||||
env.NO_PROXY = proxy.ignoredHosts.join(',')
|
||||
}
|
||||
|
||||
console.log('[createRcloneCliCommand] checking for system rclone installation')
|
||||
const hasSystem = await isSystemRcloneInstalled()
|
||||
console.log('[createRcloneCliCommand] checking for internal rclone installation')
|
||||
const hasInternal = await isInternalRcloneInstalled()
|
||||
|
||||
console.log('[createRcloneCliCommand] has system:', hasSystem)
|
||||
console.log('[createRcloneCliCommand] has internal:', hasInternal)
|
||||
|
||||
if (!hasSystem && !hasInternal) {
|
||||
console.log('[createRcloneCliCommand] no rclone installation found')
|
||||
const error = new Error('Unable to locate an rclone installation.')
|
||||
Sentry.captureException(error)
|
||||
throw error
|
||||
}
|
||||
|
||||
const flavour = hasSystem ? 'system' : 'internal'
|
||||
const commandName = flavour === 'system' ? 'rclone-system' : 'rclone-internal'
|
||||
|
||||
if (!hasSystem || activeConfig.id !== 'default') {
|
||||
console.log('[createRcloneCliCommand] setting config directory and path')
|
||||
env.RCLONE_CONFIG_DIR = configDirectory
|
||||
env.RCLONE_CONFIG = configPath.endsWith('rclone.conf')
|
||||
? configPath
|
||||
: `${configDirectory}${sep()}rclone.conf`
|
||||
}
|
||||
|
||||
if (activeConfig.isEncrypted) {
|
||||
console.log('[createRcloneCliCommand] ensuring encrypted configuration access')
|
||||
await ensureEncryptedConfigEnv(
|
||||
const env = await buildRcloneEnv({
|
||||
activeConfig,
|
||||
env,
|
||||
configDirectory,
|
||||
configPath,
|
||||
proxy: hostStore.proxy,
|
||||
rclonePath,
|
||||
autoPromptForPassword,
|
||||
commandName,
|
||||
`Please enter the current password for "${activeConfig.label}"`
|
||||
)
|
||||
}
|
||||
|
||||
if (additionalEnv) {
|
||||
console.log('[createRcloneCliCommand] setting additional environment')
|
||||
Object.assign(env, additionalEnv)
|
||||
}
|
||||
|
||||
console.log(
|
||||
'[createRcloneCliCommand] creating command, name:',
|
||||
commandName,
|
||||
'args:',
|
||||
args,
|
||||
'env:',
|
||||
env
|
||||
)
|
||||
|
||||
const command = Command.create(commandName, args, { env })
|
||||
|
||||
console.log('[createRcloneCliCommand] command created')
|
||||
additionalEnv,
|
||||
})
|
||||
|
||||
return {
|
||||
command,
|
||||
rclonePath,
|
||||
args,
|
||||
activeConfig,
|
||||
configPath,
|
||||
configDirectory,
|
||||
env,
|
||||
flavour,
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRcloneCli(args: string[], input: string[] = []) {
|
||||
const { command } = await createRcloneCliCommand(args, undefined, true)
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
const { rclonePath, env } = await createRcloneCliCommand(args, undefined, true)
|
||||
|
||||
console.log('[runRcloneCli] running command', 'args:', args, 'input:', input)
|
||||
|
||||
return await new Promise<void>((resolve, reject) => {
|
||||
command.stdout.on('data', (line) => {
|
||||
console.log('[runRcloneCli] stdout:', line)
|
||||
stdout += line
|
||||
let result: ExecResult
|
||||
try {
|
||||
result = await invoke<ExecResult>('exec_rclone', {
|
||||
path: rclonePath,
|
||||
args,
|
||||
env,
|
||||
stdinLines: input.length > 0 ? input : null,
|
||||
// Config-writing operations must not be interrupted by a timeout.
|
||||
timeoutMs: null,
|
||||
})
|
||||
command.stderr.on('data', (line) => {
|
||||
console.log('[runRcloneCli] stderr:', line)
|
||||
stderr += line
|
||||
})
|
||||
command.addListener('error', (event) => {
|
||||
console.log('[runRcloneCli] error:', event)
|
||||
const error = typeof event === 'string' ? new Error(event) : event
|
||||
Sentry.captureException(error)
|
||||
reject(error instanceof Error ? error : new Error('Unknown rclone CLI error.'))
|
||||
})
|
||||
command.addListener('close', (event) => {
|
||||
console.log('[runRcloneCli] close:', event)
|
||||
|
||||
if (event.code === 0) {
|
||||
resolve()
|
||||
return
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error))
|
||||
Sentry.captureException(err)
|
||||
throw err
|
||||
}
|
||||
|
||||
if (result.code !== 0) {
|
||||
const error = new Error(
|
||||
`rclone command failed (code ${event.code ?? 'unknown'}): ${stderr || stdout}`
|
||||
`rclone command failed (code ${result.code ?? 'unknown'}): ${result.stderr || result.stdout}`
|
||||
)
|
||||
Sentry.captureException(error)
|
||||
reject(error)
|
||||
})
|
||||
|
||||
command
|
||||
.spawn()
|
||||
.then(async (child) => {
|
||||
console.log('[runRcloneCli] child:', child)
|
||||
for (const line of input) {
|
||||
console.log('[runRcloneCli] writing input:', line)
|
||||
await child.write(`${line}\n`)
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
console.log('[runRcloneCli] input written')
|
||||
throw error
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('[runRcloneCli] error:', error)
|
||||
Sentry.captureException(error)
|
||||
reject(error)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function restartActiveRclone() {
|
||||
try {
|
||||
;(await getAllWindows())
|
||||
.filter((window) => window.label === 'main')[0]
|
||||
.emit('restart-rclone')
|
||||
// await getCurrentWindow().emit('restart-rclone')
|
||||
// The main window's store may not have rehydrated this webview's writes before the restart
|
||||
// runs — carry a full lifecycle snapshot from THIS webview's fresh stores in the payload.
|
||||
const host = useHostStore.getState()
|
||||
const persisted = usePersistedStore.getState()
|
||||
await emitToMain(RESTART_RCLONE, {
|
||||
rclonePath: persisted.rclonePath,
|
||||
defaultConfigPath: host.defaultConfigPath,
|
||||
configFiles: host.configFiles,
|
||||
activeConfigId: host.activeConfigId,
|
||||
proxy: host.proxy,
|
||||
})
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
console.error('[restartActiveRclone] failed to emit restart event', error)
|
||||
|
||||
+39
-67
@@ -10,7 +10,7 @@ import createRCDClient, {
|
||||
type OpenApiRequiredKeysOf,
|
||||
type RCDClient,
|
||||
} from 'rclone-sdk'
|
||||
import { usePersistedStore } from '../../store/persisted'
|
||||
import { selectCurrentHost, usePersistedStore } from '../../store/persisted'
|
||||
|
||||
const RE_RECONNECT = /rclone config reconnect (\S+?):/
|
||||
|
||||
@@ -52,7 +52,7 @@ let client: RCDClient | null = null
|
||||
|
||||
function getClient() {
|
||||
if (!client) {
|
||||
const currentHost = usePersistedStore.getState().currentHost
|
||||
const currentHost = selectCurrentHost(usePersistedStore.getState())
|
||||
if (!currentHost) {
|
||||
console.error('[rclone] No current host')
|
||||
throw new Error('No current host')
|
||||
@@ -86,17 +86,19 @@ type InitParam<Init> = OpenApiRequiredKeysOf<Init> extends never
|
||||
? [(Init & { [key: string]: unknown })?]
|
||||
: [Init & { [key: string]: unknown }]
|
||||
|
||||
export default async function rclone<
|
||||
Path extends OpenApiClientPathsWithMethod<RCDClient, 'post'>,
|
||||
Init extends OpenApiMaybeOptionalInit<Paths[Path], 'post'> = OpenApiMaybeOptionalInit<
|
||||
Paths[Path],
|
||||
'post'
|
||||
>,
|
||||
>(
|
||||
path: Path,
|
||||
...init: InitParam<Init>
|
||||
): Promise<OpenApiMethodResponse<RCDClient, 'post', Path, Init>> {
|
||||
console.log('[rclone] REQUEST', path, {
|
||||
type RequestResult = {
|
||||
error?: unknown
|
||||
data?: unknown
|
||||
response: Response
|
||||
}
|
||||
|
||||
// Shared transport core for the sync (POST) and async (ASYNC) RC calls. The two exported wrappers
|
||||
// differ only in the client method, the log prefix, and the return cast; everything else — client
|
||||
// acquisition and the 3-branch error triage — is identical and has always been patched in both.
|
||||
async function request(mode: 'sync' | 'async', path: string, init: any[]): Promise<unknown> {
|
||||
const label = mode === 'async' ? 'ASYNC ' : ''
|
||||
|
||||
console.log(`[rclone] ${label}REQUEST`, path, {
|
||||
params: init[0]?.params,
|
||||
body: init[0]?.body,
|
||||
})
|
||||
@@ -110,10 +112,11 @@ export default async function rclone<
|
||||
throw new Error('Failed to get client after retries')
|
||||
}
|
||||
|
||||
const result = await client.POST(
|
||||
path,
|
||||
...(init as InitParam<OpenApiMaybeOptionalInit<Paths[Path], 'post'>>)
|
||||
)
|
||||
const result = (
|
||||
mode === 'async'
|
||||
? await client.ASYNC(path as any, ...(init as [any]))
|
||||
: await client.POST(path as any, ...(init as [any]))
|
||||
) as RequestResult
|
||||
|
||||
if (result?.error) {
|
||||
console.error('[rclone] ERROR', path, { error: result.error })
|
||||
@@ -127,8 +130,7 @@ export default async function rclone<
|
||||
const data = result.data as { error?: unknown } | undefined
|
||||
if (data?.error) {
|
||||
console.error('[rclone] DATA ERROR', path, { error: data.error })
|
||||
const errMsg =
|
||||
typeof data.error === 'string' ? data.error : JSON.stringify(data.error)
|
||||
const errMsg = typeof data.error === 'string' ? data.error : JSON.stringify(data.error)
|
||||
|
||||
await handleReconnectIfNeeded(errMsg)
|
||||
throw new Error(errMsg)
|
||||
@@ -142,12 +144,12 @@ export default async function rclone<
|
||||
throw new Error(`${result.response.status} ${result.response.statusText}`)
|
||||
}
|
||||
|
||||
console.log('[rclone] RESPONSE', path, { hasData: !!result.data })
|
||||
console.log(`[rclone] ${label}RESPONSE`, path, { hasData: !!result.data })
|
||||
|
||||
return result.data as OpenApiMethodResponse<typeof client, 'post', Path, Init>
|
||||
return result.data
|
||||
}
|
||||
|
||||
export async function rcloneAsync<
|
||||
export default async function rclone<
|
||||
Path extends OpenApiClientPathsWithMethod<RCDClient, 'post'>,
|
||||
Init extends OpenApiMaybeOptionalInit<Paths[Path], 'post'> = OpenApiMaybeOptionalInit<
|
||||
Paths[Path],
|
||||
@@ -156,51 +158,21 @@ export async function rcloneAsync<
|
||||
>(
|
||||
path: Path,
|
||||
...init: InitParam<Init>
|
||||
): Promise<AsyncJobResponse> {
|
||||
console.log('[rclone] ASYNC REQUEST', path, {
|
||||
params: init[0]?.params,
|
||||
body: init[0]?.body,
|
||||
})
|
||||
|
||||
const client = await pRetry(() => getClient(), {
|
||||
'maxTimeout': 500,
|
||||
})
|
||||
|
||||
if (!client) {
|
||||
console.error('[rclone] ERROR: Failed to get client after retries', path)
|
||||
throw new Error('Failed to get client after retries')
|
||||
): Promise<OpenApiMethodResponse<RCDClient, 'post', Path, Init>> {
|
||||
return (await request('sync', path, init)) as OpenApiMethodResponse<
|
||||
RCDClient,
|
||||
'post',
|
||||
Path,
|
||||
Init
|
||||
>
|
||||
}
|
||||
|
||||
const result = await client.ASYNC(path, ...(init as [any]))
|
||||
|
||||
if (result?.error) {
|
||||
console.error('[rclone] ERROR', path, { error: result.error })
|
||||
const errMsg =
|
||||
typeof result.error === 'string' ? result.error : JSON.stringify(result.error)
|
||||
|
||||
await handleReconnectIfNeeded(errMsg)
|
||||
throw new Error(errMsg)
|
||||
}
|
||||
|
||||
const data = result.data as { error?: unknown } | undefined
|
||||
if (data?.error) {
|
||||
console.error('[rclone] DATA ERROR', path, { error: data.error })
|
||||
const errMsg =
|
||||
typeof data.error === 'string' ? data.error : JSON.stringify(data.error)
|
||||
|
||||
await handleReconnectIfNeeded(errMsg)
|
||||
throw new Error(errMsg)
|
||||
}
|
||||
|
||||
if (!result.response.ok) {
|
||||
console.error('[rclone] HTTP ERROR', path, {
|
||||
status: result.response.status,
|
||||
statusText: result.response.statusText,
|
||||
})
|
||||
throw new Error(`${result.response.status} ${result.response.statusText}`)
|
||||
}
|
||||
|
||||
console.log('[rclone] ASYNC RESPONSE', path, { hasData: !!result.data })
|
||||
|
||||
return result.data as AsyncJobResponse
|
||||
export async function rcloneAsync<
|
||||
Path extends OpenApiClientPathsWithMethod<RCDClient, 'post'>,
|
||||
Init extends OpenApiMaybeOptionalInit<Paths[Path], 'post'> = OpenApiMaybeOptionalInit<
|
||||
Paths[Path],
|
||||
'post'
|
||||
>,
|
||||
>(path: Path, ...init: InitParam<Init>): Promise<AsyncJobResponse> {
|
||||
return (await request('async', path, init)) as AsyncJobResponse
|
||||
}
|
||||
|
||||
+97
-167
@@ -1,8 +1,6 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { appLocalDataDir, sep } from '@tauri-apps/api/path'
|
||||
import { exists, mkdir, writeTextFile } from '@tauri-apps/plugin-fs'
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http'
|
||||
import { Command } from '@tauri-apps/plugin-shell'
|
||||
import createRCDClient from 'rclone-sdk'
|
||||
import { exists, mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
|
||||
import { useHostStore } from '../../store/host'
|
||||
import type { FlagValue } from '../../types/rclone'
|
||||
import { getConfigParentFolder } from '../format'
|
||||
@@ -25,70 +23,29 @@ export async function getDefaultPaths() {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSystemConfigPath() {
|
||||
console.log('[getSystemConfigPath] running system rclone')
|
||||
|
||||
const instance = Command.create('rclone-system', [
|
||||
'rcd',
|
||||
'--rc-no-auth',
|
||||
'--rc-serve',
|
||||
// '-rc-addr',
|
||||
// ':5572',
|
||||
])
|
||||
|
||||
if (!instance) {
|
||||
console.error('[getSystemConfigPath] failed to create rclone instance')
|
||||
throw new Error('Failed to create rclone instance, please try again later.')
|
||||
}
|
||||
|
||||
const output = await instance.spawn()
|
||||
|
||||
console.log('[getSystemConfigPath] spawned rclone')
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
|
||||
try {
|
||||
// no host store at this point
|
||||
const client = createRCDClient({
|
||||
baseUrl: 'http://localhost:5572',
|
||||
fetch: (request: Request) => tauriFetch(request),
|
||||
})
|
||||
|
||||
const defaultPaths = await client.POST('/config/paths', {})
|
||||
|
||||
const configPath = defaultPaths.data?.config
|
||||
if (!configPath) {
|
||||
throw new Error('Failed to fetch config path')
|
||||
}
|
||||
|
||||
return configPath.replace(DOUBLE_BACKSLASH_REGEX, '\\')
|
||||
} catch (error) {
|
||||
console.error('[getSystemConfigPath] error', error)
|
||||
if (error instanceof Error) {
|
||||
throw error
|
||||
}
|
||||
throw new Error('Failed to get default path, please try again later.')
|
||||
} finally {
|
||||
await output.kill()
|
||||
}
|
||||
/** App-private location of the default config, used when there is no system rclone to defer to. */
|
||||
export async function appPrivateDefaultConfigPath() {
|
||||
const appLocalDataDirPath = await appLocalDataDir()
|
||||
return appLocalDataDirPath + sep() + 'configs' + sep() + 'default' + sep() + 'rclone.conf'
|
||||
}
|
||||
|
||||
export async function getConfigPath({ id, validate = true }: { id: string; validate?: boolean }) {
|
||||
console.log('[getConfigPath]', id, validate)
|
||||
|
||||
const appLocalDataDirPath = await appLocalDataDir()
|
||||
console.log('[getConfigPath] appLocalDataDirPath', appLocalDataDirPath)
|
||||
|
||||
let configPath = appLocalDataDirPath + sep() + 'configs' + sep() + id + sep() + 'rclone.conf'
|
||||
|
||||
console.log('[getConfigPath] configPath', configPath)
|
||||
|
||||
if (id == 'default' && (await isSystemRcloneInstalled())) {
|
||||
const defaultPath = await getSystemConfigPath()
|
||||
|
||||
configPath = defaultPath
|
||||
console.log('[getConfigPath] configPath', configPath)
|
||||
// The "default" config lives at a location resolved once at adoption (native for a system
|
||||
// rclone, app-private otherwise) and persisted, so switching binaries never moves remotes.
|
||||
if (id === 'default') {
|
||||
const persistedDefault = useHostStore.getState().defaultConfigPath
|
||||
if (persistedDefault) {
|
||||
configPath = persistedDefault
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[getConfigPath] configPath', configPath)
|
||||
|
||||
if (validate) {
|
||||
const configExists = await exists(configPath)
|
||||
@@ -104,68 +61,102 @@ export async function getConfigPath({ id, validate = true }: { id: string; valid
|
||||
export async function createConfigFile(path: string) {
|
||||
console.log('[createConfigFile] path', path)
|
||||
|
||||
const hasConfig = await exists(path).catch(() => false)
|
||||
console.log('[createConfigFile] hasConfig', hasConfig)
|
||||
if (!hasConfig) {
|
||||
console.log('[createConfigFile] writing space character to default path (1)', path)
|
||||
if (await exists(path).catch(() => false)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await writeTextFile(path, '# Empty config file\n')
|
||||
} catch {
|
||||
// Write-first, then create the parent dir on failure and retry. Do NOT mkdir first:
|
||||
// getConfigParentFolder returns the path UNCHANGED for non-rclone.conf filenames, so an
|
||||
// unconditional mkdir could create a directory at the config file path.
|
||||
await mkdir(getConfigParentFolder(path), { recursive: true })
|
||||
await writeTextFile(path, '# Empty config file\n')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates a genuine system rclone on PATH (excluding the app's own PATH-integration pointer).
|
||||
* Returns null under Flatpak, where the host PATH is unreachable.
|
||||
*/
|
||||
export async function findSystemRclone(): Promise<string | null> {
|
||||
try {
|
||||
if (await invoke<boolean>('is_flatpak')) {
|
||||
return null
|
||||
}
|
||||
return (await invoke<string | null>('find_system_rclone')) ?? null
|
||||
} catch (error) {
|
||||
console.error('[createConfigFile] error', error)
|
||||
console.error('[findSystemRclone] error', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await exists(path).catch(() => false))) {
|
||||
console.log(
|
||||
'[createConfigFile] failed to write space character to default path (1)',
|
||||
path
|
||||
)
|
||||
const folderPath = getConfigParentFolder(path)
|
||||
console.log('[createConfigFile] creating folder', folderPath)
|
||||
await mkdir(folderPath, { recursive: true })
|
||||
console.log('[createConfigFile] created folder', folderPath)
|
||||
console.log('[createConfigFile] writing space character to default path (2)', path)
|
||||
await writeTextFile(path, '# Empty config file\n')
|
||||
const existsFinally = await exists(path).catch(() => false)
|
||||
console.log('[createConfigFile] existsFinally', existsFinally)
|
||||
/** Runs `<path> version` and returns the parsed version string; throws the detailed Rust error
|
||||
* (including the macOS Gatekeeper `xattr` hint) when the binary is unusable. */
|
||||
export async function probeRcloneBinaryOrThrow(path: string): Promise<string> {
|
||||
return await invoke<string>('validate_rclone_binary', { path })
|
||||
}
|
||||
|
||||
/** Like probeRcloneBinaryOrThrow, but returns null instead of throwing. */
|
||||
export async function validateRcloneBinary(path: string): Promise<string | null> {
|
||||
try {
|
||||
return await probeRcloneBinaryOrThrow(path)
|
||||
} catch (error) {
|
||||
console.error('[validateRcloneBinary] error', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export interface RcloneClassification {
|
||||
kind: 'system' | 'managed' | 'custom'
|
||||
version: string | null
|
||||
}
|
||||
|
||||
/** Classifies a path as system / managed / custom using canonical comparisons in Rust. */
|
||||
export async function classifyRclonePath(path: string): Promise<RcloneClassification> {
|
||||
try {
|
||||
return await invoke<RcloneClassification>('classify_rclone_path', { path })
|
||||
} catch (error) {
|
||||
console.error('[classifyRclonePath] error', error)
|
||||
return { kind: 'custom', version: null }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if rclone is installed and accessible from the system PATH
|
||||
* @returns {Promise<boolean>} True if rclone is installed and working
|
||||
* Resolves where the default config should live, driven by what the user already uses:
|
||||
* an app-private config that already holds remotes wins; otherwise a system rclone's native
|
||||
* config; otherwise the app-private default. Called once, then persisted.
|
||||
*/
|
||||
export async function isSystemRcloneInstalled() {
|
||||
console.log('[isSystemRcloneInstalled]')
|
||||
export async function resolveDefaultConfigPath(): Promise<string> {
|
||||
const appPrivate = await appPrivateDefaultConfigPath()
|
||||
|
||||
try {
|
||||
const output = await Command.create('rclone-system').execute()
|
||||
return (
|
||||
output.stdout.includes('Available commands') ||
|
||||
output.stderr.includes('Available commands')
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
if (await exists(appPrivate)) {
|
||||
const content = await readTextFile(appPrivate)
|
||||
// A section header — or an encrypted body, which has no headers — means the user
|
||||
// has real remotes here; keep them.
|
||||
if (/^\s*\[/m.test(content) || content.includes('RCLONE_ENCRYPT_V0:')) {
|
||||
return appPrivate
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[resolveDefaultConfigPath] failed reading app-private config', error)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if rclone is downloaded by the application in the app's local data directory
|
||||
* @returns {Promise<boolean>} True if downloaded rclone is present and working
|
||||
*/
|
||||
export async function isInternalRcloneInstalled() {
|
||||
console.log('[isInternalRcloneInstalled]')
|
||||
|
||||
const system = await findSystemRclone()
|
||||
if (system) {
|
||||
try {
|
||||
const output = await Command.create('rclone-internal').execute()
|
||||
// console.log('[isInternalRcloneInstalled] output', output)
|
||||
return (
|
||||
output.stdout.includes('Available commands') ||
|
||||
output.stderr.includes('Available commands')
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
const native = await invoke<string>('rclone_config_path', { path: system })
|
||||
if (native) {
|
||||
return native.replace(DOUBLE_BACKSLASH_REGEX, '\\')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[resolveDefaultConfigPath] failed reading native config path', error)
|
||||
}
|
||||
}
|
||||
|
||||
return appPrivate
|
||||
}
|
||||
|
||||
export function parseRcloneOptions(options: Record<string, FlagValue>) {
|
||||
@@ -176,7 +167,10 @@ export function parseRcloneOptions(options: Record<string, FlagValue>) {
|
||||
|
||||
export function compareVersions(version1: string, version2: string): number {
|
||||
const parseVersion = (version: string) => {
|
||||
const parts = version.split('.').map((num) => Number.parseInt(num, 10))
|
||||
// Strip a leading 'v' and any pre-release suffix (e.g. "1.74.0-beta.x") before comparing;
|
||||
// otherwise parseInt('v1') is NaN → coerced to 0, silently mis-ordering versions.
|
||||
const core = version.trim().replace(/^v/, '').split('-')[0]
|
||||
const parts = core.split('.').map((num) => Number.parseInt(num, 10))
|
||||
return {
|
||||
major: parts[0] || 0,
|
||||
minor: parts[1] || 0,
|
||||
@@ -198,67 +192,3 @@ export function compareVersions(version1: string, version2: string): number {
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const YOURS_VERSION_REGEX = /yours:\s+([^\s]+)/
|
||||
const LATEST_VERSION_REGEX = /latest:\s+([^\s]+)/
|
||||
|
||||
export async function getRcloneVersion(type?: 'system' | 'internal') {
|
||||
let instanceType = type
|
||||
if (!instanceType) {
|
||||
instanceType = (await isSystemRcloneInstalled()) ? 'system' : 'internal'
|
||||
}
|
||||
|
||||
const result = await Command.create(
|
||||
instanceType === 'system' ? 'rclone-system' : 'rclone-internal',
|
||||
['selfupdate', '--check']
|
||||
).execute()
|
||||
const output = result.stdout.trim()
|
||||
return parseRcloneVersion(output)
|
||||
}
|
||||
|
||||
export function parseRcloneVersion(output: string) {
|
||||
const yoursMatch = output.match(YOURS_VERSION_REGEX)
|
||||
const latestMatch = output.match(LATEST_VERSION_REGEX)
|
||||
|
||||
if (!yoursMatch || !latestMatch) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
yours: yoursMatch[1],
|
||||
latest: latestMatch[1],
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldUpdateRclone(versionData: { yours: string; latest: string } | null) {
|
||||
if (!versionData) {
|
||||
console.warn('[shouldUpdateRclone] received no version data:', versionData)
|
||||
return false
|
||||
}
|
||||
|
||||
const currentVersion = versionData?.yours
|
||||
const latestVersion = versionData?.latest
|
||||
|
||||
if (!currentVersion || !latestVersion) {
|
||||
console.warn('[shouldUpdateRclone] could not parse version output:', versionData)
|
||||
return false
|
||||
}
|
||||
|
||||
console.log('[shouldUpdateRclone] current version:', currentVersion)
|
||||
console.log('[shouldUpdateRclone] latest version:', latestVersion)
|
||||
|
||||
if (useHostStore.getState().lastSkippedVersion === latestVersion) {
|
||||
console.log('[shouldUpdateRclone] latest version is in the lastSkippedVersion')
|
||||
return false
|
||||
}
|
||||
|
||||
// Compare versions using the existing compareVersions function
|
||||
const versionComparison = compareVersions(currentVersion, latestVersion)
|
||||
if (versionComparison < 0) {
|
||||
console.log('[shouldUpdateRclone] internal rclone needs update')
|
||||
return true
|
||||
}
|
||||
|
||||
console.log('[shouldUpdateRclone] internal rclone is up to date')
|
||||
return false
|
||||
}
|
||||
|
||||
+6
-66
@@ -20,6 +20,12 @@ export const RCLONE_CONFIG_DEFAULTS = {
|
||||
export const RCLONE_CONF_REGEX = /[\/\\]rclone\.conf$/
|
||||
export const DOUBLE_BACKSLASH_REGEX = /\\\\/g
|
||||
|
||||
// Minimum rclone version the app's RC surface requires. The Serve feature calls
|
||||
// /serve/start|list|stop|stopall, which rclone added in 1.70.
|
||||
export const MIN_RCLONE_VERSION = '1.70.0'
|
||||
export const RCLONE_RELEASES_API = 'https://api.github.com/repos/rclone/rclone/releases?per_page=30'
|
||||
export const RCLONE_RELEASES_SHOWN = 20
|
||||
|
||||
export const SERVE_TYPES = ['dlna', 'ftp', 'sftp', 'http', 'nfs', 'restic', 's3', 'webdav'] as const
|
||||
|
||||
export const SUPPORTS_CLEANUP = [
|
||||
@@ -161,69 +167,3 @@ export function supportsPersistentEmptyFolders(backendType?: string | null) {
|
||||
if (!backendType) return true
|
||||
return !CANNOT_PERSIST_EMPTY_FOLDERS.includes(backendType.toLowerCase())
|
||||
}
|
||||
|
||||
// export const SUPPORTED_OPERATIONS = [
|
||||
// {
|
||||
// id: 'uncategorized',
|
||||
// name: 'Uncategorized',
|
||||
// icon: <FileIcon />,
|
||||
// titleColor: 'text-foreground',
|
||||
// indicatorColor: 'text-foreground-500',
|
||||
// },
|
||||
// {
|
||||
// id: 'copy',
|
||||
// name: 'Copy',
|
||||
// icon: <CopyIcon />,
|
||||
// titleColor: 'text-primary-400',
|
||||
// indicatorColor: 'text-primary-300',
|
||||
// },
|
||||
// {
|
||||
// id: 'move',
|
||||
// name: 'Move',
|
||||
// icon: <MoveIcon />,
|
||||
// titleColor: 'text-primary-400',
|
||||
// indicatorColor: 'text-primary-300',
|
||||
// },
|
||||
// {
|
||||
// id: 'delete',
|
||||
// name: 'Delete',
|
||||
// icon: <TrashIcon />,
|
||||
// titleColor: 'text-danger-400',
|
||||
// indicatorColor: 'text-danger-300',
|
||||
// },
|
||||
// {
|
||||
// id: 'sync',
|
||||
// name: 'Sync',
|
||||
// icon: <ArrowRightLeftIcon />,
|
||||
// titleColor: 'text-success-300',
|
||||
// indicatorColor: 'text-success-300',
|
||||
// },
|
||||
// {
|
||||
// id: 'bisync',
|
||||
// name: 'Bisync',
|
||||
// icon: <ArrowRightLeftIcon />,
|
||||
// titleColor: 'text-primary-400',
|
||||
// indicatorColor: 'text-primary-300',
|
||||
// },
|
||||
// {
|
||||
// id: 'mount',
|
||||
// name: 'Mount',
|
||||
// icon: <HardDriveIcon />,
|
||||
// titleColor: 'text-secondary-500',
|
||||
// indicatorColor: 'text-secondary-400',
|
||||
// },
|
||||
// {
|
||||
// id: 'purge',
|
||||
// name: 'Purge',
|
||||
// icon: <Trash2Icon />,
|
||||
// titleColor: 'text-warning-300',
|
||||
// indicatorColor: 'text-warning-300',
|
||||
// },
|
||||
// {
|
||||
// id: 'serve',
|
||||
// name: 'Serve',
|
||||
// icon: <ServerIcon />,
|
||||
// titleColor: 'text-cyan-500',
|
||||
// indicatorColor: 'text-cyan-300',
|
||||
// },
|
||||
// ] as const
|
||||
|
||||
+257
-397
@@ -1,144 +1,76 @@
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { BaseDirectory, appLocalDataDir, appLogDir, sep } from '@tauri-apps/api/path'
|
||||
import { tempDir } from '@tauri-apps/api/path'
|
||||
import { appLogDir, sep } from '@tauri-apps/api/path'
|
||||
import { ask, message } from '@tauri-apps/plugin-dialog'
|
||||
import { copyFile, exists, mkdir, readTextFile, remove } from '@tauri-apps/plugin-fs'
|
||||
import { writeFile } from '@tauri-apps/plugin-fs'
|
||||
import { exists, readTextFile } from '@tauri-apps/plugin-fs'
|
||||
import { fetch } from '@tauri-apps/plugin-http'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { exit, relaunch } from '@tauri-apps/plugin-process'
|
||||
import { Command } from '@tauri-apps/plugin-shell'
|
||||
import { useHostStore } from '../../store/host'
|
||||
import { selectActiveConfigFile, useHostStore } from '../../store/host'
|
||||
import { useStore } from '../../store/memory'
|
||||
import { usePersistedStore } from '../../store/persisted'
|
||||
import { getConfigParentFolder } from '../format'
|
||||
import notify from '../notify'
|
||||
import { openSmallWindow } from '../window'
|
||||
import { ensureEncryptedConfigEnv } from './cli'
|
||||
import { buildRcloneEnv } from './cli'
|
||||
import {
|
||||
classifyRclonePath,
|
||||
compareVersions,
|
||||
createConfigFile,
|
||||
findSystemRclone,
|
||||
getConfigPath,
|
||||
getRcloneVersion,
|
||||
getSystemConfigPath,
|
||||
isInternalRcloneInstalled,
|
||||
isSystemRcloneInstalled,
|
||||
shouldUpdateRclone,
|
||||
resolveDefaultConfigPath,
|
||||
validateRcloneBinary,
|
||||
} from './common'
|
||||
import { downloadVersion, listDownloadedVersions } from './versions'
|
||||
|
||||
export async function initRclone(args: string[]) {
|
||||
console.log('[initRclone] starting with args:', args)
|
||||
|
||||
const system = !(await invoke<boolean>('is_flatpak')) && (await isSystemRcloneInstalled())
|
||||
console.log('[initRclone] system rclone installed:', system)
|
||||
let internal = await isInternalRcloneInstalled()
|
||||
console.log('[initRclone] internal rclone installed:', internal)
|
||||
// Resolve which rclone binary to run (adopting a system/legacy binary on first launch).
|
||||
let rclonePath = await resolveActiveRclone()
|
||||
|
||||
// rclone not available, let's download it
|
||||
if (!system && !internal) {
|
||||
console.log('[initRclone] no rclone installation found, provisioning...')
|
||||
// Nothing installed anywhere — download the latest and adopt it.
|
||||
if (!rclonePath) {
|
||||
console.log('[initRclone] no rclone available, provisioning...')
|
||||
useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' })
|
||||
await openSmallWindow({
|
||||
name: 'Startup',
|
||||
url: '/startup',
|
||||
})
|
||||
const success = await provisionRclone()
|
||||
console.log('[initRclone] provision rclone result:', success)
|
||||
if (!success) {
|
||||
|
||||
const provisionedPath = await provisionRclone()
|
||||
console.log('[initRclone] provision rclone result:', provisionedPath)
|
||||
if (!provisionedPath) {
|
||||
console.error('[initRclone] provision failed, setting fatal status')
|
||||
useStore.setState({ startupStatus: 'fatal' })
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[initRclone] provision succeeded')
|
||||
usePersistedStore.getState().setRclonePath(provisionedPath)
|
||||
rclonePath = provisionedPath
|
||||
useStore.setState({ startupStatus: 'initialized' })
|
||||
|
||||
if (!['windows', 'macos'].includes(platform())) {
|
||||
usePersistedStore.setState({ hideStartup: true })
|
||||
}
|
||||
|
||||
internal = true
|
||||
}
|
||||
|
||||
const rcloneVersion = await getRcloneVersion(system ? 'system' : 'internal')
|
||||
console.log('[initRclone] rclone version:', rcloneVersion)
|
||||
// Check for a newer stable release of a managed binary: auto-update or notify.
|
||||
rclonePath = await maybeAutoUpdateRclone(rclonePath)
|
||||
|
||||
if (shouldUpdateRclone(rcloneVersion)) {
|
||||
console.log('[initRclone] needs update')
|
||||
|
||||
useStore.setState({ startupStatus: 'updating' })
|
||||
|
||||
await openSmallWindow({
|
||||
name: 'Startup',
|
||||
url: '/startup',
|
||||
// Keep the PATH-integration pointer aimed at the active binary (best-effort).
|
||||
invoke('update_path_pointer', { targetPath: rclonePath }).catch((error) => {
|
||||
console.warn('[initRclone] update_path_pointer failed', error)
|
||||
})
|
||||
|
||||
try {
|
||||
if (system) {
|
||||
console.log('[initRclone] updating system rclone')
|
||||
const code = (await invoke('update_system_rclone')) as number
|
||||
console.log('[initRclone] update_rclone code', code)
|
||||
if (code !== 0) {
|
||||
console.log(
|
||||
'[initRclone] system rclone update failed or was cancelled by user, code:',
|
||||
code
|
||||
)
|
||||
useStore.setState({ startupStatus: 'error' })
|
||||
const skipping = await ask(
|
||||
'You are running an outdated version of the CLI that could not be updated.\n\nPlease update manually and restart Rclone UI.',
|
||||
{
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
okLabel: 'Skip version',
|
||||
cancelLabel: 'Exit',
|
||||
}
|
||||
)
|
||||
console.log('[initRclone] user skipping version:', skipping)
|
||||
if (skipping) {
|
||||
console.log('[initRclone] saving skipped version:', rcloneVersion!.yours)
|
||||
useHostStore.setState({ lastSkippedVersion: rcloneVersion!.yours })
|
||||
}
|
||||
} else {
|
||||
console.log('[initRclone] system rclone updated successfully')
|
||||
useStore.setState({ startupStatus: 'updated' })
|
||||
}
|
||||
}
|
||||
if (internal) {
|
||||
console.log('[initRclone] updating internal rclone')
|
||||
const instance = Command.create('rclone-internal', ['selfupdate'])
|
||||
const updateResult = await instance.execute()
|
||||
console.log('[initRclone] updateResult', JSON.stringify(updateResult, null, 2))
|
||||
if (updateResult.code !== 0) {
|
||||
console.log(
|
||||
'[initRclone] internal rclone update failed, code:',
|
||||
updateResult.code
|
||||
)
|
||||
useStore.setState({ startupStatus: 'error' })
|
||||
} else {
|
||||
console.log('[initRclone] internal rclone updated successfully')
|
||||
useStore.setState({ startupStatus: 'updated' })
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[initRclone] failed to update rclone', error)
|
||||
useStore.setState({ startupStatus: 'error' })
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
// Resolve + materialize the default config location once, independent of the binary,
|
||||
// so switching binaries never relocates the user's remotes.
|
||||
await ensureDefaultConfig()
|
||||
|
||||
const hostState = useHostStore.getState()
|
||||
let configFiles = hostState.configFiles || []
|
||||
console.log('[initRclone] loaded config files count:', configFiles.length)
|
||||
let activeConfigFile = hostState.activeConfigFile
|
||||
console.log('[initRclone] active config file:', activeConfigFile?.id)
|
||||
|
||||
if (system) {
|
||||
const defaultPath = await getSystemConfigPath()
|
||||
console.log('[initRclone] defaultPath', defaultPath)
|
||||
|
||||
await createConfigFile(defaultPath)
|
||||
console.log('[initRclone] created system config file')
|
||||
}
|
||||
|
||||
const existingDefaultConfig = configFiles.find((config) => config.id === 'default')
|
||||
configFiles = configFiles.filter((config) => config.id !== 'default')
|
||||
@@ -159,6 +91,10 @@ export async function initRclone(args: string[]) {
|
||||
console.log('[initRclone] added default config to list')
|
||||
useHostStore.setState({ configFiles })
|
||||
|
||||
// Resolve the active config against the REBUILT list so a persisted id of 'default' resolves.
|
||||
let activeConfigFile = selectActiveConfigFile(useHostStore.getState())
|
||||
console.log('[initRclone] active config file:', activeConfigFile?.id)
|
||||
|
||||
if (!activeConfigFile) {
|
||||
console.log('[initRclone] no active config file, setting default')
|
||||
activeConfigFile = configFiles[0]
|
||||
@@ -168,13 +104,7 @@ export async function initRclone(args: string[]) {
|
||||
}
|
||||
|
||||
console.log('[initRclone] set active config file to:', activeConfigFile.id)
|
||||
useHostStore.setState({ activeConfigFile })
|
||||
}
|
||||
|
||||
if (internal && activeConfigFile.id === 'default') {
|
||||
console.log('[initRclone] creating internal default config file')
|
||||
const defaultInternalPath = await getConfigPath({ id: 'default', validate: false })
|
||||
await createConfigFile(defaultInternalPath)
|
||||
useHostStore.getState().setActiveConfigFile(activeConfigFile.id!)
|
||||
}
|
||||
|
||||
let configFolderPath = activeConfigFile.sync
|
||||
@@ -203,11 +133,12 @@ export async function initRclone(args: string[]) {
|
||||
okLabel: 'OK',
|
||||
})
|
||||
activeConfigFile = configFiles[0]
|
||||
configFolderPath = getConfigParentFolder(
|
||||
await getConfigPath({ id: 'default', validate: true })
|
||||
)
|
||||
// Rebind configPath too (not just configFolderPath): otherwise the readTextFile below
|
||||
// reads the stale, known-missing synced path and the fallback dead-ends in an exit.
|
||||
configPath = await getConfigPath({ id: 'default', validate: true })
|
||||
configFolderPath = getConfigParentFolder(configPath)
|
||||
console.log('[initRclone] switched to default config')
|
||||
useHostStore.setState({ activeConfigFile: configFiles[0] })
|
||||
useHostStore.getState().setActiveConfigFile(configFiles[0].id!)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,36 +158,14 @@ export async function initRclone(args: string[]) {
|
||||
} else {
|
||||
console.log('[initRclone] no stored password configured')
|
||||
}
|
||||
|
||||
if (!activeConfigFile.isEncrypted) {
|
||||
console.log('[initRclone] updating config file encryption flag')
|
||||
const updatedConfigFile = { ...activeConfigFile, isEncrypted: true }
|
||||
const updatedConfigFiles = configFiles.map((config) =>
|
||||
config.id === activeConfigFile!.id ? updatedConfigFile : config
|
||||
)
|
||||
useHostStore.setState({
|
||||
configFiles: updatedConfigFiles,
|
||||
activeConfigFile: updatedConfigFile,
|
||||
})
|
||||
console.log('[initRclone] saved updated encryption flag')
|
||||
|
||||
// Update activeConfigFile reference for the rest of the function
|
||||
activeConfigFile = updatedConfigFile
|
||||
}
|
||||
} else if (activeConfigFile.isEncrypted) {
|
||||
console.log('[initRclone] config file is not encrypted, clearing encryption flag')
|
||||
const updatedConfigFile = { ...activeConfigFile, isEncrypted: false }
|
||||
const updatedConfigFiles = configFiles.map((config) =>
|
||||
config.id === activeConfigFile!.id ? updatedConfigFile : config
|
||||
)
|
||||
useHostStore.setState({
|
||||
configFiles: updatedConfigFiles,
|
||||
activeConfigFile: updatedConfigFile,
|
||||
})
|
||||
console.log('[initRclone] cleared encryption flag')
|
||||
|
||||
// Update activeConfigFile reference for the rest of the function
|
||||
activeConfigFile = updatedConfigFile
|
||||
// Reconcile the stored encryption flag with the file's actual contents. The local rebind
|
||||
// is load-bearing: buildRcloneEnv below reads activeConfigFile to build the password env.
|
||||
if (activeConfigFile.isEncrypted !== isEncrypted) {
|
||||
console.log('[initRclone] reconciling encryption flag to', isEncrypted)
|
||||
useHostStore.getState().updateConfigFile(activeConfigFile.id!, { isEncrypted })
|
||||
activeConfigFile = { ...activeConfigFile, isEncrypted }
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('[initRclone] could not read config file', error)
|
||||
@@ -273,10 +182,7 @@ export async function initRclone(args: string[]) {
|
||||
return
|
||||
}
|
||||
|
||||
const extraParams: { env: Record<string, string> } = {
|
||||
env: {},
|
||||
}
|
||||
|
||||
// Proxy connectivity check (informational; the env vars themselves are set by buildRcloneEnv).
|
||||
if (hostState.proxy) {
|
||||
console.log('[initRclone] proxy configured:', hostState.proxy.url)
|
||||
try {
|
||||
@@ -302,51 +208,29 @@ export async function initRclone(args: string[]) {
|
||||
return
|
||||
}
|
||||
}
|
||||
console.log('[initRclone] setting proxy environment variables')
|
||||
extraParams.env.http_proxy = hostState.proxy.url
|
||||
extraParams.env.https_proxy = hostState.proxy.url
|
||||
extraParams.env.HTTP_PROXY = hostState.proxy.url
|
||||
extraParams.env.HTTPS_PROXY = hostState.proxy.url
|
||||
extraParams.env.no_proxy = hostState.proxy.ignoredHosts.join(',')
|
||||
extraParams.env.NO_PROXY = hostState.proxy.ignoredHosts.join(',')
|
||||
console.log(
|
||||
'[initRclone] proxy env vars set, ignored hosts:',
|
||||
hostState.proxy.ignoredHosts.length
|
||||
)
|
||||
}
|
||||
|
||||
if (internal || activeConfigFile.id !== 'default') {
|
||||
console.log('[initRclone] setting custom config path:', configFolderPath)
|
||||
extraParams.env.RCLONE_CONFIG_DIR = configFolderPath
|
||||
extraParams.env.RCLONE_CONFIG = `${configFolderPath}${sep()}rclone.conf`
|
||||
}
|
||||
|
||||
const commandName = system ? 'rclone-system' : internal ? 'rclone-internal' : null
|
||||
|
||||
if (activeConfigFile.isEncrypted && commandName) {
|
||||
console.log('[initRclone] ensuring encrypted configuration access')
|
||||
let env: Record<string, string>
|
||||
try {
|
||||
await ensureEncryptedConfigEnv(
|
||||
activeConfigFile,
|
||||
extraParams.env,
|
||||
true,
|
||||
commandName,
|
||||
`Please enter the current password for "${activeConfigFile.label}"`
|
||||
)
|
||||
env = await buildRcloneEnv({
|
||||
activeConfig: activeConfigFile,
|
||||
configDirectory: configFolderPath,
|
||||
configPath,
|
||||
proxy: hostState.proxy,
|
||||
rclonePath,
|
||||
autoPromptForPassword: true,
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'Password prompt cancelled by user.') {
|
||||
console.error('[initRclone] password prompt cancelled by user')
|
||||
const response = await message(
|
||||
'Password is required for encrypted configurations.',
|
||||
{
|
||||
const response = await message('Password is required for encrypted configurations.', {
|
||||
title: 'Password Required',
|
||||
kind: 'error',
|
||||
buttons: {
|
||||
cancel: 'Close',
|
||||
ok: 'Try Again',
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
console.log('[initRclone] message response:', response)
|
||||
if (response === 'Try Again') {
|
||||
await relaunch()
|
||||
@@ -357,246 +241,222 @@ export async function initRclone(args: string[]) {
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[initRclone] extraParams', extraParams)
|
||||
|
||||
if (system) {
|
||||
console.log('[initRclone] creating system rclone command instance')
|
||||
const instance = Command.create('rclone-system', args, extraParams)
|
||||
console.log('[initRclone] returning system rclone instance')
|
||||
return { system: instance }
|
||||
}
|
||||
if (internal) {
|
||||
console.log('[initRclone] creating internal rclone command instance')
|
||||
const instance = Command.create('rclone-internal', args, extraParams)
|
||||
console.log('[initRclone] returning internal rclone instance')
|
||||
return { internal: instance }
|
||||
}
|
||||
|
||||
console.error('[initRclone] no rclone installation available')
|
||||
throw new Error('Failed to initialize rclone, please try again later.')
|
||||
console.log('[initRclone] returning rclone command', { path: rclonePath, args })
|
||||
return { path: rclonePath, args, env }
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads and provisions the latest version of rclone for the current platform
|
||||
* @throws {Error} If architecture detection fails or installation is unsuccessful
|
||||
* @returns {Promise<void>}
|
||||
* Resolves the active rclone binary path: validates the persisted selection (self-healing a
|
||||
* managed version whose absolute path moved), otherwise adopts a system / legacy / downloaded
|
||||
* binary. Returns null when nothing is available so the caller can provision.
|
||||
*/
|
||||
export async function provisionRclone() {
|
||||
console.log('[provisionRclone] starting provisioning process')
|
||||
async function resolveActiveRclone(): Promise<string | null> {
|
||||
const persisted = usePersistedStore.getState()
|
||||
const stored = persisted.rclonePath
|
||||
|
||||
console.log('[provisionRclone] fetching latest version info')
|
||||
const currentVersionString = await fetch('https://downloads.rclone.org/version.txt').then(
|
||||
(res) => res.text()
|
||||
if (stored) {
|
||||
const version = await validateRcloneBinary(stored)
|
||||
if (version) {
|
||||
console.log('[resolveActiveRclone] using stored rclone', stored, version)
|
||||
return stored
|
||||
}
|
||||
console.warn('[resolveActiveRclone] stored rclone path is unusable:', stored)
|
||||
|
||||
// Self-heal a managed version whose absolute path moved (e.g. home-dir rename).
|
||||
const match = stored.match(/rclone-versions[/\\]v([^/\\]+)/)
|
||||
if (match) {
|
||||
const healed = await invoke<string | null>('managed_version_path', {
|
||||
version: match[1],
|
||||
})
|
||||
if (healed && (await validateRcloneBinary(healed))) {
|
||||
console.log('[resolveActiveRclone] self-healed managed path ->', healed)
|
||||
persisted.setRclonePath(healed)
|
||||
return healed
|
||||
}
|
||||
}
|
||||
// fall through to the adoption ladder
|
||||
}
|
||||
|
||||
// Fold any legacy single-slot binary into the versioned library first (idempotent), so it
|
||||
// remains visible even when a system rclone ends up active.
|
||||
let legacyAdopted: { version: string; path: string } | null = null
|
||||
try {
|
||||
legacyAdopted = await invoke<{ version: string; path: string } | null>(
|
||||
'adopt_legacy_rclone'
|
||||
)
|
||||
console.log('[provisionRclone] currentVersionString', currentVersionString)
|
||||
} catch (error) {
|
||||
console.error('[resolveActiveRclone] adopt_legacy_rclone failed', error)
|
||||
}
|
||||
|
||||
const currentVersion = currentVersionString.split('v')?.[1]?.trim()
|
||||
// 1. Genuine system rclone — offered, not silently adopted, so the user decides whether the
|
||||
// app tracks their system install or manages its own copy. Answering persists a path, so
|
||||
// the question fires only while no usable path is stored.
|
||||
const system = await findSystemRclone()
|
||||
if (system) {
|
||||
const systemVersion = await validateRcloneBinary(system)
|
||||
if (systemVersion) {
|
||||
const useSystem = await ask(
|
||||
`Found rclone v${systemVersion} at:\n${system}\n\nUse it as the app's rclone? Otherwise the app will manage its own copy. You can switch anytime in Settings.`,
|
||||
{
|
||||
title: 'System rclone detected',
|
||||
kind: 'info',
|
||||
okLabel: 'Use system rclone',
|
||||
cancelLabel: 'Manage separately',
|
||||
}
|
||||
)
|
||||
if (useSystem) {
|
||||
persisted.setRclonePath(system)
|
||||
return system
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!currentVersion) {
|
||||
console.error('[provisionRclone] failed to get latest version from string')
|
||||
// 2. The just-adopted legacy binary. Re-probe it: when the version already existed in the
|
||||
// library, adopt_legacy_rclone returns that pre-existing binary without validating it.
|
||||
if (legacyAdopted?.path && (await validateRcloneBinary(legacyAdopted.path))) {
|
||||
persisted.setRclonePath(legacyAdopted.path)
|
||||
return legacyAdopted.path
|
||||
}
|
||||
|
||||
// 3. Newest already-downloaded managed version that still runs — a broken binary must fall
|
||||
// through to provisioning instead of being re-adopted.
|
||||
try {
|
||||
const downloaded = await listDownloadedVersions()
|
||||
for (const candidate of downloaded) {
|
||||
if (await validateRcloneBinary(candidate.path)) {
|
||||
persisted.setRclonePath(candidate.path)
|
||||
return candidate.path
|
||||
}
|
||||
console.warn(
|
||||
'[resolveActiveRclone] skipping unusable downloaded version:',
|
||||
candidate.path
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[resolveActiveRclone] list_downloaded_rclone_versions failed', error)
|
||||
}
|
||||
|
||||
// 4. Nothing available — caller provisions.
|
||||
return null
|
||||
}
|
||||
|
||||
let rcloneUpdateChecked = false
|
||||
|
||||
/**
|
||||
* For a managed binary: checks downloads.rclone.org for a newer stable release, once per app
|
||||
* session (so switching versions in Settings doesn't immediately undo a pin). Downloads and
|
||||
* adopts it when auto-update is on; otherwise notifies once per version that an update can be
|
||||
* run from Settings. Never blocks startup on failure.
|
||||
*/
|
||||
async function maybeAutoUpdateRclone(currentPath: string): Promise<string> {
|
||||
if (rcloneUpdateChecked) {
|
||||
return currentPath
|
||||
}
|
||||
rcloneUpdateChecked = true
|
||||
|
||||
try {
|
||||
const active = await classifyRclonePath(currentPath)
|
||||
if (active.kind !== 'managed' || !active.version) {
|
||||
return currentPath
|
||||
}
|
||||
|
||||
const versionString = await fetch('https://downloads.rclone.org/version.txt', {
|
||||
connectTimeout: 5000,
|
||||
}).then((res) => res.text())
|
||||
const latest = versionString.split('v')?.[1]?.trim()
|
||||
if (!latest || compareVersions(latest, active.version) <= 0) {
|
||||
return currentPath
|
||||
}
|
||||
|
||||
const persisted = usePersistedStore.getState()
|
||||
|
||||
if (!persisted.autoUpdateRclone) {
|
||||
if (persisted.lastNotifiedRcloneVersion !== latest) {
|
||||
usePersistedStore.setState({ lastNotifiedRcloneVersion: latest })
|
||||
await notify({
|
||||
title: 'Rclone update available',
|
||||
body: `rclone v${latest} is available. You can update from Settings → Binary.`,
|
||||
})
|
||||
}
|
||||
return currentPath
|
||||
}
|
||||
|
||||
console.log('[maybeAutoUpdateRclone] updating', active.version, '->', latest)
|
||||
// Startup-window status only (never startupDisplayed): showStartup opens the window and,
|
||||
// finding 'updated', shows the update message; a failed update is restored below so the
|
||||
// window can't stick on 'updating' with no TAP TO START.
|
||||
useStore.setState({ startupStatus: 'updating' })
|
||||
const newPath = await downloadVersion(latest)
|
||||
persisted.setRclonePath(newPath)
|
||||
useStore.setState({ startupStatus: 'updated' })
|
||||
return newPath
|
||||
} catch (error) {
|
||||
console.log('[maybeAutoUpdateRclone] update check skipped', error)
|
||||
// Restore so 'updating' can't stick — but only if we set it: a failure before the
|
||||
// download (classify, version fetch) must not downgrade a status another path already
|
||||
// promoted (provisioning sets 'initialized' before this runs). When the Startup window
|
||||
// is already open (provisioning path), restore 'initialized' — 'initializing' renders
|
||||
// no TAP TO START and showStartup early-returns on startupDisplayed, stranding the
|
||||
// window. Do NOT set 'error' — this path is offline-safe and silently continues on the
|
||||
// existing binary.
|
||||
const store = useStore.getState()
|
||||
if (store.startupStatus === 'updating') {
|
||||
useStore.setState({
|
||||
startupStatus: store.startupDisplayed ? 'initialized' : 'initializing',
|
||||
})
|
||||
}
|
||||
return currentPath
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves (once) and materializes the default config location for the active host. */
|
||||
async function ensureDefaultConfig() {
|
||||
const host = useHostStore.getState()
|
||||
let defaultConfigPath = host.defaultConfigPath
|
||||
if (!defaultConfigPath) {
|
||||
defaultConfigPath = await resolveDefaultConfigPath()
|
||||
console.log('[ensureDefaultConfig] resolved default config path', defaultConfigPath)
|
||||
host.setDefaultConfigPath(defaultConfigPath)
|
||||
}
|
||||
await createConfigFile(defaultConfigPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the latest rclone release into the versioned library and returns its absolute path,
|
||||
* or false on failure. The download/extract/verify pipeline lives in Rust.
|
||||
*/
|
||||
export async function provisionRclone(): Promise<string | false> {
|
||||
console.log('[provisionRclone] starting')
|
||||
|
||||
let version: string | undefined
|
||||
try {
|
||||
const versionString = await fetch('https://downloads.rclone.org/version.txt').then((res) =>
|
||||
res.text()
|
||||
)
|
||||
version = versionString.split('v')?.[1]?.trim()
|
||||
} catch (error) {
|
||||
console.error('[provisionRclone] failed to fetch latest version', error)
|
||||
}
|
||||
|
||||
if (!version) {
|
||||
await message('Failed to get latest rclone version, please try again later.')
|
||||
return false
|
||||
}
|
||||
console.log('[provisionRclone] currentVersion', currentVersion)
|
||||
console.log('[provisionRclone] latest version', version)
|
||||
|
||||
const currentPlatform = platform()
|
||||
console.log('[provisionRclone] currentPlatform', currentPlatform)
|
||||
|
||||
const currentOs = currentPlatform === 'macos' ? 'osx' : currentPlatform
|
||||
console.log('[provisionRclone] currentOs', currentOs)
|
||||
|
||||
console.log('[provisionRclone] getting temp directory path')
|
||||
let tempDirPath = await tempDir()
|
||||
if (tempDirPath.endsWith(sep())) {
|
||||
tempDirPath = tempDirPath.slice(0, -1)
|
||||
}
|
||||
console.log('[provisionRclone] tempDirPath', tempDirPath)
|
||||
|
||||
console.log('[provisionRclone] detecting system architecture')
|
||||
const arch = (await invoke('get_arch')) as 'arm64' | 'amd64' | '386' | 'unknown'
|
||||
console.log('[provisionRclone] arch', arch)
|
||||
|
||||
if (arch === 'unknown') {
|
||||
console.error('[provisionRclone] failed to get architecture')
|
||||
await message('Failed to get current arch, please try again later.')
|
||||
return false
|
||||
}
|
||||
|
||||
const downloadUrl = `https://downloads.rclone.org/v${currentVersion}/rclone-v${currentVersion}-${currentOs}-${arch}.zip`
|
||||
console.log('[provisionRclone] downloadUrl', downloadUrl)
|
||||
|
||||
console.log('[provisionRclone] downloading rclone binary')
|
||||
const downloadedFile = await fetch(downloadUrl).then((res) => res.arrayBuffer())
|
||||
console.log('[provisionRclone] download complete, size:', downloadedFile.byteLength)
|
||||
|
||||
console.log('[provisionRclone] checking if temp rclone directory exists')
|
||||
let tempDirExists = false
|
||||
let path: string
|
||||
try {
|
||||
tempDirExists = await exists('rclone', {
|
||||
baseDir: BaseDirectory.Temp,
|
||||
})
|
||||
console.log('[provisionRclone] tempDirExists', tempDirExists)
|
||||
path = await downloadVersion(version)
|
||||
} catch (error) {
|
||||
console.error('[provisionRclone] download failed', error)
|
||||
Sentry.captureException(error)
|
||||
console.error('[provisionRclone] failed to check if rclone temp dir exists', error)
|
||||
}
|
||||
|
||||
if (tempDirExists) {
|
||||
console.log('[provisionRclone] removing existing temp directory')
|
||||
try {
|
||||
await remove('rclone', {
|
||||
recursive: true,
|
||||
baseDir: BaseDirectory.Temp,
|
||||
})
|
||||
console.log('[provisionRclone] removed rclone temp dir')
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
console.error('[provisionRclone] failed to remove rclone temp dir', error)
|
||||
await message('Failed to provision rclone.')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[provisionRclone] creating temp directory')
|
||||
try {
|
||||
await mkdir('rclone', {
|
||||
baseDir: BaseDirectory.Temp,
|
||||
})
|
||||
console.log('[provisionRclone] created rclone temp dir')
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
console.error('[provisionRclone] failed to create rclone temp dir', error)
|
||||
await message('Failed to provision rclone.')
|
||||
return false
|
||||
}
|
||||
|
||||
const zipPath = [
|
||||
tempDirPath,
|
||||
'rclone',
|
||||
`rclone-v${currentVersion}-${currentOs}-${arch}.zip`,
|
||||
].join(sep())
|
||||
console.log('[provisionRclone] zipPath', zipPath)
|
||||
|
||||
console.log('[provisionRclone] writing zip file to disk')
|
||||
try {
|
||||
await writeFile(zipPath, new Uint8Array(downloadedFile))
|
||||
console.log('[provisionRclone] wrote zip file successfully')
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
console.error('[provisionRclone] failed to write zip file', error)
|
||||
await message('Failed to provision rclone.')
|
||||
return false
|
||||
}
|
||||
|
||||
const extractPath = `${tempDirPath}${sep()}rclone${sep()}extracted`
|
||||
console.log('[provisionRclone] extracting zip file to:', extractPath)
|
||||
try {
|
||||
await invoke('unzip_file', {
|
||||
zipPath,
|
||||
outputFolder: extractPath,
|
||||
})
|
||||
console.log('[provisionRclone] successfully unzipped file')
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
console.error('[provisionRclone] failed to unzip file', error)
|
||||
await message('Failed to provision rclone.')
|
||||
return false
|
||||
}
|
||||
|
||||
const unarchivedPath = [
|
||||
tempDirPath,
|
||||
'rclone',
|
||||
'extracted',
|
||||
`rclone-v${currentVersion}-${currentOs}-${arch}`,
|
||||
].join(sep())
|
||||
console.log('[provisionRclone] unarchivedPath', unarchivedPath)
|
||||
|
||||
const binaryName = currentPlatform === 'windows' ? 'rclone.exe' : 'rclone'
|
||||
console.log('[provisionRclone] binaryName', binaryName)
|
||||
|
||||
const rcloneBinaryPath = unarchivedPath + sep() + binaryName
|
||||
console.log('[provisionRclone] rcloneBinaryPath', rcloneBinaryPath)
|
||||
|
||||
console.log('[provisionRclone] verifying extracted binary exists')
|
||||
try {
|
||||
const binaryExists = await exists(rcloneBinaryPath)
|
||||
console.log('[provisionRclone] rcloneBinaryPathExists', binaryExists)
|
||||
if (!binaryExists) {
|
||||
console.error('[provisionRclone] binary not found in expected location')
|
||||
throw new Error('Could not find rclone binary in zip')
|
||||
}
|
||||
} catch (error) {
|
||||
Sentry.captureException(error)
|
||||
console.error('[provisionRclone] failed to check if rclone binary exists', error)
|
||||
await message('Failed to provision rclone.')
|
||||
return false
|
||||
}
|
||||
|
||||
console.log('[provisionRclone] getting app local data directory')
|
||||
const appLocalDataDirPath = await appLocalDataDir()
|
||||
console.log('[provisionRclone] appLocalDataDirPath', appLocalDataDirPath)
|
||||
|
||||
console.log('[provisionRclone] checking if app local data directory exists')
|
||||
const appLocalDataDirPathExists = await exists(appLocalDataDirPath)
|
||||
console.log('[provisionRclone] appLocalDataDirPathExists', appLocalDataDirPathExists)
|
||||
|
||||
if (!appLocalDataDirPathExists) {
|
||||
console.log('[provisionRclone] creating app local data directory')
|
||||
await mkdir(appLocalDataDirPath, {
|
||||
recursive: true,
|
||||
})
|
||||
console.log('[provisionRclone] appLocalDataDirPath created')
|
||||
}
|
||||
|
||||
const targetBinaryPath = `${appLocalDataDirPath}${sep()}${binaryName}`
|
||||
console.log('[provisionRclone] targetBinaryPath', targetBinaryPath)
|
||||
|
||||
console.log('[provisionRclone] copying binary to final location')
|
||||
const maxCopyRetries = 3
|
||||
for (let attempt = 1; attempt <= maxCopyRetries; attempt++) {
|
||||
console.log(`[provisionRclone] copy attempt ${attempt}/${maxCopyRetries}`)
|
||||
try {
|
||||
await copyFile(rcloneBinaryPath, targetBinaryPath)
|
||||
console.log('[provisionRclone] copied rclone binary successfully')
|
||||
break
|
||||
} catch (copyError) {
|
||||
console.log(
|
||||
`[provisionRclone] attempt ${attempt}/${maxCopyRetries} failed to copy:`,
|
||||
copyError
|
||||
await message(
|
||||
`Failed to download rclone: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
|
||||
if (attempt < maxCopyRetries) {
|
||||
const waitTime = attempt * 1000
|
||||
console.log(`[provisionRclone] waiting ${waitTime}ms before retry`)
|
||||
// Wait a bit before retrying
|
||||
await new Promise((resolve) => setTimeout(resolve, waitTime))
|
||||
} else {
|
||||
console.error('[provisionRclone] all copy attempts failed', copyError)
|
||||
Sentry.captureException(copyError, {
|
||||
extra: {
|
||||
rcloneBinaryPath,
|
||||
targetBinaryPath,
|
||||
},
|
||||
})
|
||||
throw new Error(
|
||||
'Failed to provision rclone, file is busy. Install cli manually or try again later.'
|
||||
)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
console.log('[provisionRclone] verifying installation')
|
||||
const hasInstalled = await isInternalRcloneInstalled()
|
||||
console.log('[provisionRclone] installation verified:', hasInstalled)
|
||||
|
||||
if (!hasInstalled) {
|
||||
console.error('[provisionRclone] installation verification failed')
|
||||
throw new Error('Failed to install rclone')
|
||||
}
|
||||
|
||||
console.log('[provisionRclone] rclone has been installed successfully')
|
||||
|
||||
return true
|
||||
console.log('[provisionRclone] installed at', path)
|
||||
return path
|
||||
}
|
||||
|
||||
@@ -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
@@ -9,6 +9,7 @@ import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { exit } from '@tauri-apps/plugin-process'
|
||||
import { usePersistedStore } from '../store/persisted'
|
||||
import { CLOSE_APP, emitToMain } from './events'
|
||||
import { openWindow } from './window'
|
||||
|
||||
async function buildMenu() {
|
||||
@@ -136,7 +137,7 @@ async function buildMenu() {
|
||||
id: 'quit',
|
||||
text: 'Quit',
|
||||
action: async () => {
|
||||
await getCurrentWindow().emit('close-app')
|
||||
await emitToMain(CLOSE_APP)
|
||||
},
|
||||
})
|
||||
menuItems.push(quitItem)
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export async function openFullWindow({
|
||||
url: string
|
||||
hideTitleBar?: boolean
|
||||
}) {
|
||||
console.log('[openFullWindow] ', name, url)
|
||||
console.log('[openFullWindow]', name)
|
||||
await invoke('open_full_window', { name, url, hideTitleBar })
|
||||
return WebviewWindow.getByLabel(name)
|
||||
}
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import { getVersion as getUiVersion } from '@tauri-apps/api/app'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { Channel, invoke } from '@tauri-apps/api/core'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
|
||||
import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'
|
||||
import { ask, message } from '@tauri-apps/plugin-dialog'
|
||||
import { debug, error, info, trace, warn } from '@tauri-apps/plugin-log'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
|
||||
import { exit, relaunch } from '@tauri-apps/plugin-process'
|
||||
import type { Child } from '@tauri-apps/plugin-shell'
|
||||
import { check } from '@tauri-apps/plugin-updater'
|
||||
import { type Update, check } from '@tauri-apps/plugin-updater'
|
||||
import { CronExpressionParser } from 'cron-parser'
|
||||
import { defaultOptions } from 'tauri-plugin-sentry-api'
|
||||
import { getDeepLinkUrl, handleDeepLinkUrl } from './lib/deep'
|
||||
import { LOCAL_HOST_ID, getHostInfo } from './lib/hosts'
|
||||
import { CLOSE_APP, RELAUNCH_APP, RESTART_RCLONE, type RestartRclonePayload } from './lib/events'
|
||||
import { LOCAL_HOST_ID, RC_PORT, getHostInfo, makeLocalHost } from './lib/hosts'
|
||||
import { validateLicense } from './lib/license'
|
||||
import notify from './lib/notify'
|
||||
import queryClient from './lib/query'
|
||||
@@ -33,13 +33,35 @@ import { initRclone } from './lib/rclone/init'
|
||||
import { initTray } from './lib/tray'
|
||||
import { openSmallWindow } from './lib/window'
|
||||
import { initHostStore, useHostStore } from './store/host'
|
||||
import { waitForStoreHydration } from './store/lib'
|
||||
import { useStore } from './store/memory'
|
||||
import { usePersistedStore } from './store/persisted'
|
||||
import { selectCurrentHost, usePersistedStore } from './store/persisted'
|
||||
import type { ScheduledTask } from './types/schedules'
|
||||
|
||||
let currentRcloneChild: Child | null = null
|
||||
let rcloneListenersRegistered = false
|
||||
|
||||
// Mirrors zookeeper.rs RcloneEvent — only 'close' is emitted.
|
||||
type RcloneDaemonEvent = {
|
||||
kind: 'close'
|
||||
code: number | null
|
||||
intentional: boolean
|
||||
}
|
||||
|
||||
async function killRcloneDaemon() {
|
||||
// Rust daemon state is authoritative: the command no-ops (returns false) when nothing is
|
||||
// tracked, so we must not gate on a local mirror that could defeat its reload-orphan guard.
|
||||
let killed = false
|
||||
try {
|
||||
killed = await invoke<boolean>('kill_rclone_daemon', {})
|
||||
} catch (error) {
|
||||
console.error('[killRcloneDaemon] failed to kill rclone daemon', error)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
if (killed) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Sentry.init({
|
||||
...defaultOptions,
|
||||
@@ -98,19 +120,14 @@ async function checkFlatpakPermissions() {
|
||||
|
||||
async function waitForHydration() {
|
||||
console.log('[waitForHydration] waiting for store hydration')
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
if (!usePersistedStore.persist.hasHydrated()) {
|
||||
await waitForHydration()
|
||||
}
|
||||
await waitForStoreHydration(() => usePersistedStore.persist.hasHydrated())
|
||||
console.log('[waitForHydration] store hydrated')
|
||||
}
|
||||
|
||||
async function initializeHostStore() {
|
||||
console.log('[initializeHostStore] initializing')
|
||||
const currentHost = usePersistedStore.getState().currentHost
|
||||
// Default to 'local' if fresh install/no host selected
|
||||
const hostId = currentHost?.id || 'local'
|
||||
const hostId = usePersistedStore.getState().currentHostId || LOCAL_HOST_ID
|
||||
|
||||
await initHostStore(hostId)
|
||||
|
||||
@@ -120,7 +137,7 @@ async function initializeHostStore() {
|
||||
async function checkHostReachability(): Promise<void> {
|
||||
console.log('[checkHostReachability] checking host reachability')
|
||||
|
||||
const currentHost = usePersistedStore.getState().currentHost
|
||||
const currentHost = selectCurrentHost(usePersistedStore.getState())
|
||||
|
||||
// If no host selected or local host, skip check (local rclone hasn't started yet)
|
||||
if (!currentHost || currentHost.id === LOCAL_HOST_ID) {
|
||||
@@ -164,13 +181,15 @@ async function checkHostReachability(): Promise<void> {
|
||||
console.log('[checkHostReachability] retrying connection')
|
||||
isReachable = await checkReachability()
|
||||
} else {
|
||||
// User chose to use local host
|
||||
// User chose to use local host. Upsert the local host and point at it in one write so
|
||||
// currentHostId never dangles.
|
||||
console.log('[checkHostReachability] switching to local host')
|
||||
const hosts = usePersistedStore.getState().hosts
|
||||
const localHost = hosts.find((h) => h.id === LOCAL_HOST_ID)
|
||||
if (localHost) {
|
||||
usePersistedStore.setState({ currentHost: localHost })
|
||||
}
|
||||
usePersistedStore.setState((prev) => ({
|
||||
hosts: prev.hosts.some((h) => h.id === LOCAL_HOST_ID)
|
||||
? prev.hosts
|
||||
: [...prev.hosts, makeLocalHost()],
|
||||
currentHostId: LOCAL_HOST_ID,
|
||||
}))
|
||||
// Re-initialize host store for local
|
||||
await initHostStore(LOCAL_HOST_ID)
|
||||
return
|
||||
@@ -226,8 +245,7 @@ async function checkAlreadyRunning() {
|
||||
console.log('[checkAlreadyRunning]')
|
||||
|
||||
try {
|
||||
const rcPort = 5572
|
||||
const running = await invoke<boolean>('is_rclone_running', { port: rcPort })
|
||||
const running = await invoke<boolean>('is_rclone_running', { port: RC_PORT })
|
||||
console.log('[checkAlreadyRunning] running', running)
|
||||
|
||||
if (running) {
|
||||
@@ -278,24 +296,26 @@ async function registerRcloneWindowListeners() {
|
||||
|
||||
const window = getCurrentWindow()
|
||||
|
||||
await window.listen('close-app', async () => {
|
||||
console.log('[registerRcloneWindowListeners] close-app requested')
|
||||
|
||||
const transfers = await queryClient.ensureQueryData({
|
||||
// Kill the daemon BEFORE exit/relaunch — this ordering is the entire point of these listeners.
|
||||
const shutdown = async (mode: 'quit' | 'relaunch') => {
|
||||
// A dead daemon means "no active transfers": don't let a listTransfers throw make quit a
|
||||
// silent no-op.
|
||||
const transfers = await queryClient
|
||||
.ensureQueryData({
|
||||
queryKey: ['transfers', 'list', 'all'],
|
||||
queryFn: async () => await listTransfers(),
|
||||
staleTime: 10_000, // 10 seconds
|
||||
gcTime: 60_000, // 1 minute
|
||||
})
|
||||
.catch(() => null)
|
||||
|
||||
if (transfers?.active && transfers.active.length > 0) {
|
||||
const answer = await ask('All active transfers will be stopped.', {
|
||||
title: 'Exit',
|
||||
kind: 'info',
|
||||
okLabel: 'Quit',
|
||||
okLabel: mode === 'relaunch' ? 'Relaunch' : 'Quit',
|
||||
cancelLabel: 'Cancel',
|
||||
})
|
||||
|
||||
if (!answer) {
|
||||
return
|
||||
}
|
||||
@@ -304,84 +324,61 @@ async function registerRcloneWindowListeners() {
|
||||
const cloudflaredTunnel = useStore.getState().cloudflaredTunnel
|
||||
if (cloudflaredTunnel) {
|
||||
try {
|
||||
console.log('[close-app] stopping cloudflared tunnel')
|
||||
console.log('[shutdown] stopping cloudflared tunnel')
|
||||
await invoke('stop_cloudflared_tunnel', { pid: cloudflaredTunnel.pid })
|
||||
useStore.setState({ cloudflaredTunnel: null })
|
||||
} catch (error) {
|
||||
console.error('[close-app] failed to stop cloudflared tunnel', error)
|
||||
console.error('[shutdown] failed to stop cloudflared tunnel', error)
|
||||
}
|
||||
}
|
||||
|
||||
const child = currentRcloneChild
|
||||
|
||||
if (child) {
|
||||
try {
|
||||
await child.kill()
|
||||
} catch (error) {
|
||||
console.error('[close-app] failed to kill rclone child', error)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
currentRcloneChild = null
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
await killRcloneDaemon()
|
||||
|
||||
if (mode === 'relaunch') {
|
||||
await relaunch()
|
||||
} else {
|
||||
await exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
await window.listen(CLOSE_APP, async () => {
|
||||
console.log('[registerRcloneWindowListeners] close-app requested')
|
||||
await shutdown('quit')
|
||||
})
|
||||
console.log('[registerRcloneWindowListeners] close-app listener registered')
|
||||
|
||||
await window.listen('relaunch-app', async () => {
|
||||
await window.listen(RELAUNCH_APP, async () => {
|
||||
console.log('[registerRcloneWindowListeners] relaunch-app requested')
|
||||
|
||||
const transfers = await queryClient.ensureQueryData({
|
||||
queryKey: ['transfers', 'list', 'all'],
|
||||
queryFn: async () => await listTransfers(),
|
||||
staleTime: 10_000, // 10 seconds
|
||||
gcTime: 60_000, // 1 minute
|
||||
})
|
||||
|
||||
if (transfers?.active && transfers.active.length > 0) {
|
||||
const answer = await ask('All active transfers will be stopped.', {
|
||||
title: 'Exit',
|
||||
kind: 'info',
|
||||
okLabel: 'Relaunch',
|
||||
cancelLabel: 'Cancel',
|
||||
})
|
||||
if (!answer) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const cloudflaredTunnel = useStore.getState().cloudflaredTunnel
|
||||
if (cloudflaredTunnel) {
|
||||
try {
|
||||
console.log('[close-app] stopping cloudflared tunnel')
|
||||
await invoke('stop_cloudflared_tunnel', { pid: cloudflaredTunnel.pid })
|
||||
useStore.setState({ cloudflaredTunnel: null })
|
||||
} catch (error) {
|
||||
console.error('[close-app] failed to stop cloudflared tunnel', error)
|
||||
}
|
||||
}
|
||||
|
||||
const child = currentRcloneChild
|
||||
|
||||
if (child) {
|
||||
try {
|
||||
await child.kill()
|
||||
} catch (error) {
|
||||
console.error('[relaunch-app] failed to kill rclone child', error)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
currentRcloneChild = null
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
|
||||
await relaunch()
|
||||
await shutdown('relaunch')
|
||||
})
|
||||
console.log('[registerRcloneWindowListeners] relaunch-app listener registered')
|
||||
|
||||
await window.listen('restart-rclone', async () => {
|
||||
await window.listen<RestartRclonePayload>(RESTART_RCLONE, async (event) => {
|
||||
console.log('[registerRcloneWindowListeners] restart-rclone requested')
|
||||
|
||||
// Trust the payload: the initiating webview's store writes may not have reached the main
|
||||
// window yet. Apply BEFORE the in-flight guard so state isn't lost on a skipped restart.
|
||||
// configFiles BEFORE activeConfigId (setActiveConfigFile resolves against state.configFiles
|
||||
// and nulls on a miss). NEVER log the raw payload — it carries config `pass`.
|
||||
const payload = event.payload
|
||||
if (payload) {
|
||||
if (payload.rclonePath) {
|
||||
usePersistedStore.getState().setRclonePath(payload.rclonePath)
|
||||
}
|
||||
if (payload.defaultConfigPath) {
|
||||
useHostStore.getState().setDefaultConfigPath(payload.defaultConfigPath)
|
||||
}
|
||||
if (payload.configFiles) {
|
||||
useHostStore.setState({ configFiles: payload.configFiles })
|
||||
}
|
||||
if (payload.activeConfigId) {
|
||||
useHostStore.getState().setActiveConfigFile(payload.activeConfigId)
|
||||
}
|
||||
if (payload.proxy !== undefined) {
|
||||
useHostStore.setState({ proxy: payload.proxy })
|
||||
}
|
||||
}
|
||||
|
||||
if (useStore.getState().isRestartingRclone) {
|
||||
console.log('[restart-rclone] restart already in progress, ignoring request')
|
||||
return
|
||||
@@ -390,18 +387,7 @@ async function registerRcloneWindowListeners() {
|
||||
useStore.setState({ isRestartingRclone: true })
|
||||
|
||||
try {
|
||||
const child = currentRcloneChild
|
||||
|
||||
if (child) {
|
||||
try {
|
||||
await child.kill()
|
||||
} catch (error) {
|
||||
console.error('[restart-rclone] failed to exit rclone process', error)
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
currentRcloneChild = null
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
}
|
||||
await killRcloneDaemon()
|
||||
|
||||
await startRclone()
|
||||
} catch (error) {
|
||||
@@ -454,25 +440,28 @@ async function startRclone() {
|
||||
return await exit(0)
|
||||
}
|
||||
|
||||
const command = rclone?.system || rclone?.internal
|
||||
|
||||
if (!command) {
|
||||
if (!rclone) {
|
||||
console.error('[startRclone] initRclone returned without a runnable command')
|
||||
Sentry.captureException(new Error('initRclone returned without a runnable command.'))
|
||||
return
|
||||
}
|
||||
|
||||
command.addListener('close', async (event) => {
|
||||
console.log('close', event)
|
||||
currentRcloneChild = null
|
||||
const { path, args: rcloneArgs, env } = rclone
|
||||
|
||||
const channel = new Channel<RcloneDaemonEvent>()
|
||||
channel.onmessage = async (payload) => {
|
||||
console.log('[startRclone] daemon close', payload)
|
||||
|
||||
// Killed intentionally (restart / quit) — the initiator handles what happens next.
|
||||
if (payload.intentional) {
|
||||
return
|
||||
}
|
||||
|
||||
if (platform() === 'windows') {
|
||||
return await exit(0)
|
||||
}
|
||||
|
||||
console.log('event.code', event.code)
|
||||
|
||||
if (event.code === 143 || event.code === 1) {
|
||||
if (payload.code === 143 || payload.code === 1) {
|
||||
Sentry.captureException(new Error('Rclone has crashed'))
|
||||
const confirmed = await ask('Rclone has crashed', {
|
||||
title: 'Error',
|
||||
@@ -485,16 +474,37 @@ async function startRclone() {
|
||||
}
|
||||
await relaunch()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
command.addListener('error', (event) => {
|
||||
console.log('error', event)
|
||||
console.log('[startRclone] spawning rclone daemon')
|
||||
let pid: number
|
||||
try {
|
||||
pid = await invoke<number>('spawn_rclone', {
|
||||
path,
|
||||
args: rcloneArgs,
|
||||
env,
|
||||
onEvent: channel,
|
||||
})
|
||||
|
||||
console.log('[startRclone] starting rclone')
|
||||
const childProcess = await command.spawn()
|
||||
currentRcloneChild = childProcess
|
||||
console.log('[startRclone] running rclone')
|
||||
} catch (error) {
|
||||
console.error('[startRclone] failed to spawn rclone daemon', error)
|
||||
Sentry.captureException(error)
|
||||
// A relaunch re-runs the resolution ladder, which can heal a broken binary.
|
||||
const confirmed = await ask(
|
||||
`Rclone failed to start: ${error instanceof Error ? error.message : String(error)}`,
|
||||
{
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
okLabel: 'Relaunch',
|
||||
cancelLabel: 'Exit',
|
||||
}
|
||||
)
|
||||
if (confirmed) {
|
||||
await relaunch()
|
||||
return
|
||||
}
|
||||
return await exit(0)
|
||||
}
|
||||
console.log('[startRclone] running rclone, pid', pid)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
}
|
||||
@@ -593,7 +603,14 @@ async function showStartup() {
|
||||
}
|
||||
|
||||
console.log('[showStartup] startup not displayed, setting displayed and status')
|
||||
useStore.setState({ startupDisplayed: true, startupStatus: 'initialized' })
|
||||
// Upgrade-only: a successful auto-update's 'updated' status must survive so its message shows;
|
||||
// everything else (normal launch with null status, or a failed update restored to
|
||||
// 'initializing') becomes 'initialized'. Never unconditionally clobber, or 'updated' is lost.
|
||||
const currentStartupStatus = useStore.getState().startupStatus
|
||||
useStore.setState({
|
||||
startupDisplayed: true,
|
||||
startupStatus: currentStartupStatus === 'updated' ? 'updated' : 'initialized',
|
||||
})
|
||||
console.log('[showStartup] store updated with startup displayed and status set')
|
||||
await openSmallWindow({
|
||||
name: 'Startup',
|
||||
@@ -617,7 +634,7 @@ async function resumeTasks() {
|
||||
}
|
||||
|
||||
const scheduledTasks = useHostStore.getState().scheduledTasks
|
||||
const activeConfigId = useHostStore.getState().activeConfigFile?.id
|
||||
const activeConfigId = useHostStore.getState().activeConfigId
|
||||
|
||||
console.log('[resumeTasks] found', scheduledTasks.length, 'scheduled tasks')
|
||||
console.log('[resumeTasks] activeConfigId:', activeConfigId)
|
||||
@@ -892,6 +909,45 @@ async function handleTask(task: ScheduledTask) {
|
||||
}
|
||||
}
|
||||
|
||||
async function installUpdate(update: Update, required: boolean) {
|
||||
const confirmed = await ask(
|
||||
'You are running an outdated version of Rclone UI. Please update to the latest version.',
|
||||
{
|
||||
title: required ? 'Update Required' : 'Update Available',
|
||||
kind: 'info',
|
||||
okLabel: 'Update',
|
||||
cancelLabel: required ? 'Exit' : 'Cancel',
|
||||
}
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
console.log('[checkVersion] user cancelled update')
|
||||
if (required) {
|
||||
return await exit(0)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[checkVersion] downloading and installing update')
|
||||
|
||||
await update.downloadAndInstall()
|
||||
|
||||
console.log('[checkVersion] update downloaded and installed')
|
||||
|
||||
await message('Rclone UI has been updated. Please restart the application.', {
|
||||
title: 'Update Complete',
|
||||
kind: 'info',
|
||||
okLabel: 'Restart',
|
||||
})
|
||||
|
||||
console.log('[checkVersion] relaunching app')
|
||||
|
||||
// Direct relaunch: at checkVersion time no daemon exists yet (before startRclone), so the
|
||||
// shutdown path is unnecessary; the old emit fired before any listener existed and was
|
||||
// dropped, leaving the app running the old version.
|
||||
await relaunch()
|
||||
}
|
||||
|
||||
async function checkVersion() {
|
||||
console.log('[checkVersion]')
|
||||
|
||||
@@ -939,70 +995,10 @@ async function checkVersion() {
|
||||
|
||||
if (compareVersions(currentVersion, minimumVersion) < 0) {
|
||||
console.log('[checkVersion] currentVersion is outdated')
|
||||
|
||||
const confirmed = await ask(
|
||||
'You are running an outdated version of Rclone UI. Please update to the latest version.',
|
||||
{
|
||||
title: 'Update Required',
|
||||
kind: 'info',
|
||||
okLabel: 'Update',
|
||||
cancelLabel: 'Exit',
|
||||
}
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
console.log('[checkVersion] user cancelled update')
|
||||
return await exit(0)
|
||||
}
|
||||
|
||||
console.log('[checkVersion] downloading and installing update')
|
||||
|
||||
await receivedUpdate.downloadAndInstall()
|
||||
|
||||
console.log('[checkVersion] update downloaded and installed')
|
||||
|
||||
await message('Rclone UI has been updated. Please restart the application.', {
|
||||
title: 'Update Complete',
|
||||
kind: 'info',
|
||||
okLabel: 'Restart',
|
||||
})
|
||||
|
||||
console.log('[checkVersion] relaunching app')
|
||||
|
||||
await getCurrentWindow().emit('relaunch-app')
|
||||
await installUpdate(receivedUpdate, true)
|
||||
} else if (compareVersions(currentVersion, okVersion) < 0) {
|
||||
console.log('[checkVersion] checking for update')
|
||||
|
||||
const confirmed = await ask(
|
||||
'You are running an outdated version of Rclone UI. Please update to the latest version.',
|
||||
{
|
||||
title: 'Update Available',
|
||||
kind: 'info',
|
||||
okLabel: 'Update',
|
||||
cancelLabel: 'Cancel',
|
||||
}
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
console.log('[checkVersion] user cancelled update')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[checkVersion] downloading and installing update')
|
||||
|
||||
await receivedUpdate.downloadAndInstall()
|
||||
|
||||
console.log('[checkVersion] update downloaded and installed')
|
||||
|
||||
await message('Rclone UI has been updated. Please restart the application.', {
|
||||
title: 'Update Complete',
|
||||
kind: 'info',
|
||||
okLabel: 'Restart',
|
||||
})
|
||||
|
||||
console.log('[checkVersion] relaunching app')
|
||||
|
||||
await getCurrentWindow().emit('relaunch-app')
|
||||
await installUpdate(receivedUpdate, false)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[checkVersion] error', error)
|
||||
@@ -1011,17 +1007,7 @@ async function checkVersion() {
|
||||
}
|
||||
|
||||
async function checkRclone() {
|
||||
let currentHost = usePersistedStore.getState().currentHost
|
||||
|
||||
if (!currentHost) {
|
||||
currentHost = {
|
||||
id: 'local',
|
||||
name: 'Local Machine',
|
||||
url: 'http://localhost:5572',
|
||||
os: 'linux',
|
||||
cliVersion: 'unknown',
|
||||
}
|
||||
}
|
||||
let currentHost = selectCurrentHost(usePersistedStore.getState()) ?? makeLocalHost()
|
||||
|
||||
let hostInfo = await getHostInfo({
|
||||
url: currentHost.url,
|
||||
@@ -1037,13 +1023,7 @@ async function checkRclone() {
|
||||
kind: 'error',
|
||||
}
|
||||
)
|
||||
currentHost = {
|
||||
id: 'local',
|
||||
name: 'Local Machine',
|
||||
url: 'http://localhost:5572',
|
||||
os: 'linux',
|
||||
cliVersion: 'unknown',
|
||||
}
|
||||
currentHost = makeLocalHost()
|
||||
|
||||
hostInfo = await getHostInfo({
|
||||
url: currentHost.url,
|
||||
@@ -1078,9 +1058,9 @@ async function checkRclone() {
|
||||
|
||||
console.log('[checkRclone] setting currentHost', currentHost)
|
||||
|
||||
usePersistedStore.setState({ currentHost })
|
||||
usePersistedStore.setState((prev) => ({
|
||||
hosts: [...prev.hosts.filter((h) => h.id !== currentHost!.id), currentHost],
|
||||
hosts: [...prev.hosts.filter((h) => h.id !== currentHost.id), currentHost],
|
||||
currentHostId: currentHost.id,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1089,18 +1069,16 @@ getCurrentWindow().listen('tauri://close-requested', async () => {
|
||||
await getCurrentWindow().destroy()
|
||||
})
|
||||
|
||||
// maybe place this inside handleDeepLink?
|
||||
function processDeepLink(url: string) {
|
||||
const deepLinkUrl = getDeepLinkUrl(url)
|
||||
console.log('deep link url', deepLinkUrl)
|
||||
handleDeepLinkUrl(deepLinkUrl)
|
||||
useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' })
|
||||
}
|
||||
|
||||
onOpenUrl((urls) => {
|
||||
console.log('deep links while running', urls)
|
||||
const receivedUrl = urls[0]
|
||||
|
||||
const deepLinkUrl = getDeepLinkUrl(receivedUrl)
|
||||
|
||||
console.log('deep link url', deepLinkUrl)
|
||||
|
||||
handleDeepLinkUrl(deepLinkUrl)
|
||||
|
||||
useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' })
|
||||
processDeepLink(urls[0])
|
||||
})
|
||||
|
||||
async function handleDeepLink() {
|
||||
@@ -1110,24 +1088,14 @@ async function handleDeepLink() {
|
||||
console.log('[handleDeepLink] no deep links found')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[handleDeepLink] getting deep link url')
|
||||
const deepLinkUrl = getDeepLinkUrl(urls[0])
|
||||
console.log('[handleDeepLink] deep link url', deepLinkUrl)
|
||||
|
||||
console.log('[handleDeepLink] handling deep link url')
|
||||
handleDeepLinkUrl(deepLinkUrl)
|
||||
console.log('[handleDeepLink] deep link url handled')
|
||||
|
||||
console.log('[handleDeepLink] setting startup displayed and status')
|
||||
useStore.setState({ startupDisplayed: true, startupStatus: 'initializing' })
|
||||
console.log('[handleDeepLink] startup displayed and status set')
|
||||
processDeepLink(urls[0])
|
||||
}
|
||||
|
||||
waitForHydration()
|
||||
.then(() => checkFlatpakPermissions())
|
||||
.then(() => initializeHostStore())
|
||||
.then(() => checkHostReachability())
|
||||
.then(() => registerRcloneWindowListeners())
|
||||
.then(() => checkVersion())
|
||||
.then(() => validateInstance())
|
||||
.then(() => checkAlreadyRunning())
|
||||
|
||||
Generated
-10
@@ -29,7 +29,6 @@
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@tauri-apps/plugin-store": "^2.4.3",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
@@ -5984,15 +5983,6 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-shell": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz",
|
||||
"integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-store": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.3.tgz",
|
||||
|
||||
@@ -52,7 +52,6 @@
|
||||
"@tauri-apps/plugin-opener": "^2.5.4",
|
||||
"@tauri-apps/plugin-os": "^2.3.2",
|
||||
"@tauri-apps/plugin-process": "^2.3.1",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@tauri-apps/plugin-store": "^2.4.3",
|
||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
||||
"@tauri-apps/plugin-window-state": "^2.4.1",
|
||||
|
||||
Generated
+79
-54
@@ -258,6 +258,7 @@ dependencies = [
|
||||
"sentry",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sysinfo",
|
||||
"tar",
|
||||
"tauri",
|
||||
@@ -276,11 +277,12 @@ dependencies = [
|
||||
"tauri-plugin-prevent-default",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-sentry",
|
||||
"tauri-plugin-shell",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-store",
|
||||
"tauri-plugin-updater",
|
||||
"tinyfiledialogs-rs",
|
||||
"windows-sys 0.59.0",
|
||||
"winreg 0.52.0",
|
||||
"x11rb",
|
||||
"zbus",
|
||||
"zip 0.6.6",
|
||||
@@ -5294,44 +5296,12 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shared_child"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"sigchld",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "sigchld"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"os_pipe",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
@@ -6101,27 +6071,6 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-shell"
|
||||
version = "2.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
"open",
|
||||
"os_pipe",
|
||||
"regex",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"shared_child",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.4.2"
|
||||
@@ -7599,6 +7548,15 @@ dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||
dependencies = [
|
||||
"windows-targets 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -7650,6 +7608,21 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.48.5",
|
||||
"windows_aarch64_msvc 0.48.5",
|
||||
"windows_i686_gnu 0.48.5",
|
||||
"windows_i686_msvc 0.48.5",
|
||||
"windows_x86_64_gnu 0.48.5",
|
||||
"windows_x86_64_gnullvm 0.48.5",
|
||||
"windows_x86_64_msvc 0.48.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -7707,6 +7680,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -7725,6 +7704,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -7743,6 +7728,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -7773,6 +7764,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -7791,6 +7788,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -7809,6 +7812,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -7827,6 +7836,12 @@ version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.48.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -7872,6 +7887,16 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.52.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a277a57398d4bfa075df44f501a17cfdf8542d224f0d36095a2adc7aee4ef0a5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
|
||||
@@ -24,7 +24,6 @@ log = "0.4"
|
||||
tauri = { version = "2.11.1", features = [ "tray-icon", "image-ico",
|
||||
"image-png", "config-json5" ] }
|
||||
tauri-plugin-log = "2.8.0"
|
||||
tauri-plugin-shell = "2.3.5"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-fs = "2.5.1"
|
||||
tauri-plugin-opener = "2.5.4"
|
||||
@@ -50,6 +49,7 @@ tinyfiledialogs = { package = "tinyfiledialogs-rs", version = "3.21.3", features
|
||||
tauri-plugin-deep-link = "2.4.9"
|
||||
flate2 = "1.1.9"
|
||||
tar = "0.4.45"
|
||||
sha2 = "0.10"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
cocoa = "0.26"
|
||||
@@ -59,3 +59,7 @@ objc = "0.2"
|
||||
zbus = { version = "5" }
|
||||
x11rb = "0.13"
|
||||
gtk = "0.18"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.52"
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
@@ -168,46 +168,6 @@
|
||||
"core:webview:allow-internal-toggle-devtools",
|
||||
"core:webview:allow-get-all-webviews",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"shell:default",
|
||||
"shell:allow-kill",
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-stdin-write",
|
||||
"shell:allow-open",
|
||||
{
|
||||
"identifier": "shell:allow-execute",
|
||||
"allow": [
|
||||
{
|
||||
"name": "rclone-system",
|
||||
"cmd": "rclone",
|
||||
"args": true
|
||||
},
|
||||
{
|
||||
"name": "rclone-internal",
|
||||
"cmd": "$APPLOCALDATA/rclone",
|
||||
"args": true
|
||||
},
|
||||
{
|
||||
"name": "umount",
|
||||
"cmd": "umount",
|
||||
"args": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "shell:allow-spawn",
|
||||
"allow": [
|
||||
{
|
||||
"name": "rclone-system",
|
||||
"cmd": "rclone",
|
||||
"args": true
|
||||
},
|
||||
{
|
||||
"name": "rclone-internal",
|
||||
"cmd": "$APPLOCALDATA/rclone",
|
||||
"args": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"log:default",
|
||||
"dialog:default",
|
||||
{
|
||||
|
||||
@@ -18,7 +18,6 @@ pub fn make_transparent(window: &WebviewWindow) -> Result<(), tauri::Error> {
|
||||
let ns_window: id = msg_send![webview_obj, window];
|
||||
let bg_color = NSColor::colorWithSRGBRed_green_blue_alpha_(nil, 0.0, 0.0, 0.0, 0.0);
|
||||
let _: id = msg_send![ns_window, setBackgroundColor: bg_color];
|
||||
// let _: () = msg_send![ns_window, setIgnoresMouseEvents:true];
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
@@ -155,8 +154,6 @@ pub async fn open_window(
|
||||
) -> Result<(), String> {
|
||||
if let Some(existing) = app_handle.get_webview_window(&name) {
|
||||
existing.set_focus().map_err(|e| e.to_string())?;
|
||||
#[cfg(target_os = "linux")]
|
||||
focus_window_linux(&app_handle, &existing);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -286,8 +283,6 @@ pub async fn open_small_window(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let os = std::env::consts::OS;
|
||||
|
||||
let mut builder = WebviewWindowBuilder::new(&app_handle, &name, WebviewUrl::App(url.into()))
|
||||
.title(&name)
|
||||
.inner_size(800.0, 500.0)
|
||||
|
||||
+32
-171
@@ -1,12 +1,10 @@
|
||||
use machine_uid;
|
||||
use sentry;
|
||||
use std::fs::{self, File};
|
||||
use std::path::Path;
|
||||
use std::fs;
|
||||
use sysinfo::System;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use tauri_plugin_sentry;
|
||||
use tinyfiledialogs as tfd;
|
||||
use zip::ZipArchive;
|
||||
|
||||
#[path = "../common/shortcut.rs"]
|
||||
mod shortcut;
|
||||
@@ -14,6 +12,8 @@ mod shortcut;
|
||||
#[path = "../common/window.rs"]
|
||||
mod window;
|
||||
|
||||
mod zookeeper;
|
||||
|
||||
use shortcut::{
|
||||
ensure_toolbar_window, set_toolbar_shortcut, show_toolbar_window, DEFAULT_TOOLBAR_SHORTCUT,
|
||||
};
|
||||
@@ -127,48 +127,7 @@ fn has_flatpak_permissions() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn unzip_file(zip_path: &str, output_folder: &str) -> Result<(), String> {
|
||||
// Open the zip file
|
||||
let file = File::open(zip_path).map_err(|e| e.to_string())?;
|
||||
|
||||
// Create output directory if it doesn't exist
|
||||
fs::create_dir_all(output_folder).map_err(|e| e.to_string())?;
|
||||
|
||||
// Create ZIP archive reader
|
||||
let mut archive = ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
|
||||
// Extract everything
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).map_err(|e| e.to_string())?;
|
||||
let outpath = Path::new(output_folder).join(file.name());
|
||||
|
||||
if file.name().ends_with('/') || file.name().ends_with('\\') {
|
||||
fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
if let Some(p) = outpath.parent() {
|
||||
fs::create_dir_all(p).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let mut outfile = File::create(&outpath).map_err(|e| e.to_string())?;
|
||||
std::io::copy(&mut file, &mut outfile).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Get and set permissions (Unix only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Some(mode) = file.unix_mode() {
|
||||
fs::set_permissions(&outpath, fs::Permissions::from_mode(mode))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stop_pid(pid: u32, timeout_ms: Option<u64>) -> Result<(), String> {
|
||||
pub(crate) async fn kill_pid(pid: u32, timeout_ms: Option<u64>) -> Result<(), String> {
|
||||
let timeout = timeout_ms.unwrap_or(5000);
|
||||
|
||||
#[cfg(any(
|
||||
@@ -325,7 +284,7 @@ async fn stop_rclone_processes(timeout_ms: Option<u64>) -> Result<u32, String> {
|
||||
|
||||
let mut stopped: u32 = 0;
|
||||
for pid in pids {
|
||||
match stop_pid(pid, Some(timeout)).await {
|
||||
match kill_pid(pid, Some(timeout)).await {
|
||||
Ok(()) => stopped += 1,
|
||||
Err(_e) => {}
|
||||
}
|
||||
@@ -334,10 +293,6 @@ async fn stop_rclone_processes(timeout_ms: Option<u64>) -> Result<u32, String> {
|
||||
Ok(stopped)
|
||||
}
|
||||
|
||||
async fn prompt_password(title: String, message: String) -> Result<Option<String>, String> {
|
||||
prompt_text(title, message, None, Some(true)).await
|
||||
}
|
||||
|
||||
async fn prompt_text(
|
||||
title: String,
|
||||
message: String,
|
||||
@@ -559,6 +514,7 @@ async fn start_cloudflared_tunnel(app: tauri::AppHandle) -> Result<(u32, String)
|
||||
|
||||
// Start cloudflared tunnel
|
||||
let mut child = SysCommand::new(&cloudflared_path)
|
||||
// keep in sync with RC_PORT in lib/hosts.ts
|
||||
.args(&["tunnel", "--url", "http://localhost:5572"])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
@@ -602,7 +558,7 @@ async fn start_cloudflared_tunnel(app: tauri::AppHandle) -> Result<(u32, String)
|
||||
}
|
||||
|
||||
// If we didn't get a URL, kill the process and return error
|
||||
let _ = stop_pid(pid, Some(2000)).await;
|
||||
let _ = kill_pid(pid, Some(2000)).await;
|
||||
Err("Failed to get tunnel URL from cloudflared".to_string())
|
||||
}
|
||||
|
||||
@@ -611,7 +567,7 @@ async fn stop_cloudflared_tunnel(pid: u32) -> Result<(), String> {
|
||||
use std::time::Duration;
|
||||
|
||||
// Cloudflared takes ~5s to gracefully shut down, so give it enough time
|
||||
match stop_pid(pid, Some(6000)).await {
|
||||
match kill_pid(pid, Some(6000)).await {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) => {
|
||||
// Wait a bit for the process to fully terminate
|
||||
@@ -752,119 +708,6 @@ async fn test_proxy_connection(proxy_url: String) -> Result<String, String> {
|
||||
Err(last_error.unwrap_or_else(|| "All proxy tests failed".to_string()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_system_rclone() -> Result<i32, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use std::process::Command as SysCommand;
|
||||
|
||||
fn quote_posix(value: &str) -> String {
|
||||
let escaped = value.replace("'", "'\\''");
|
||||
format!("'{}'", escaped)
|
||||
}
|
||||
|
||||
let mut cmdline =
|
||||
String::from("PATH=/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:$PATH; ");
|
||||
cmdline.push_str("e_posix("rclone"));
|
||||
cmdline.push(' ');
|
||||
cmdline.push_str("e_posix("selfupdate"));
|
||||
|
||||
// Escape for embedding inside an AppleScript string literal
|
||||
let applescript_cmd = cmdline.replace('\\', "\\\\").replace('"', "\\\"");
|
||||
let prompt = "Rclone UI needs permission to run rclone selfupdate.";
|
||||
let script = format!(
|
||||
"do shell script \"{}\" with administrator privileges with prompt \"{}\"",
|
||||
applescript_cmd,
|
||||
prompt.replace('"', "\\\"")
|
||||
);
|
||||
|
||||
let status = SysCommand::new("osascript")
|
||||
.arg("-e")
|
||||
.arg(script)
|
||||
.status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(status.code().unwrap_or(0));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::process::Command as SysCommand;
|
||||
|
||||
let path_env =
|
||||
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin";
|
||||
|
||||
// Try PolicyKit first (graphical auth prompt on most desktops)
|
||||
let mut pkexec_args: Vec<String> = Vec::new();
|
||||
pkexec_args.push("--description".to_string());
|
||||
pkexec_args.push("Rclone UI needs to run rclone selfupdate".to_string());
|
||||
pkexec_args.push("env".to_string());
|
||||
pkexec_args.push(path_env.to_string());
|
||||
pkexec_args.push("rclone".to_string());
|
||||
pkexec_args.push("selfupdate".to_string());
|
||||
|
||||
match SysCommand::new("pkexec").args(&pkexec_args).status() {
|
||||
Ok(status) => return Ok(status.code().unwrap_or(0)),
|
||||
Err(_e) => {
|
||||
// Fallback to sudo with custom prompt (works if the user has NOPASSWD or cached credentials)
|
||||
let mut sudo_env = std::collections::HashMap::new();
|
||||
sudo_env.insert("SUDO_PROMPT", "Rclone UI needs permission to run rclone selfupdate. Please enter your password: ");
|
||||
|
||||
let mut sudo_args: Vec<String> = Vec::new();
|
||||
sudo_args.push("-n".to_string());
|
||||
sudo_args.push("env".to_string());
|
||||
sudo_args.push(path_env.to_string());
|
||||
sudo_args.push("rclone".to_string());
|
||||
sudo_args.push("selfupdate".to_string());
|
||||
|
||||
let status = SysCommand::new("sudo")
|
||||
.envs(&sudo_env)
|
||||
.args(&sudo_args)
|
||||
.status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(status.code().unwrap_or(0));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::process::Command as SysCommand;
|
||||
|
||||
fn quote_ps(value: &str) -> String {
|
||||
// PowerShell single-quote escaping: ' -> ''
|
||||
format!("'{}'", value.replace('\'', "''"))
|
||||
}
|
||||
|
||||
let file_path = quote_ps("rclone");
|
||||
let arg_list = String::from("@('selfupdate')");
|
||||
|
||||
let ps_script = format!(
|
||||
"$p = Start-Process -Verb RunAs -WindowStyle Hidden -PassThru -FilePath {file} -ArgumentList {args}; \n\
|
||||
$p.WaitForExit();\n\
|
||||
exit $p.ExitCode",
|
||||
file = file_path,
|
||||
args = arg_list
|
||||
);
|
||||
|
||||
let status = SysCommand::new("powershell")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
&ps_script,
|
||||
])
|
||||
.status()
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(status.code().unwrap_or(0));
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
{
|
||||
Err("Unsupported platform".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let client = sentry::init((
|
||||
@@ -887,6 +730,9 @@ pub fn run() {
|
||||
}
|
||||
|
||||
let mut app = builder
|
||||
.manage::<zookeeper::SharedDaemonState>(std::sync::Mutex::new(
|
||||
zookeeper::DaemonState::default(),
|
||||
))
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_sentry::init_with_no_injection(&client))
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
@@ -903,21 +749,17 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_log::Builder::new().build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_prevent_default::debug())
|
||||
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
unzip_file,
|
||||
get_arch,
|
||||
get_uid,
|
||||
is_rclone_running,
|
||||
stop_rclone_processes,
|
||||
prompt,
|
||||
stop_pid,
|
||||
update_toolbar_shortcut,
|
||||
show_toolbar,
|
||||
update_system_rclone,
|
||||
test_proxy_connection,
|
||||
is_flatpak,
|
||||
is_linux_mint,
|
||||
@@ -929,7 +771,22 @@ pub fn run() {
|
||||
unlock_windows,
|
||||
start_cloudflared_tunnel,
|
||||
stop_cloudflared_tunnel,
|
||||
extract_tgz
|
||||
extract_tgz,
|
||||
zookeeper::exec_rclone,
|
||||
zookeeper::spawn_rclone,
|
||||
zookeeper::kill_rclone_daemon,
|
||||
zookeeper::validate_rclone_binary,
|
||||
zookeeper::rclone_config_path,
|
||||
zookeeper::find_system_rclone,
|
||||
zookeeper::classify_rclone_path,
|
||||
zookeeper::list_downloaded_rclone_versions,
|
||||
zookeeper::delete_rclone_version,
|
||||
zookeeper::adopt_legacy_rclone,
|
||||
zookeeper::managed_version_path,
|
||||
zookeeper::download_rclone_version,
|
||||
zookeeper::update_path_pointer,
|
||||
zookeeper::get_rclone_path_integration,
|
||||
zookeeper::set_rclone_path_integration
|
||||
])
|
||||
.setup(|app| {
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -962,6 +819,10 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Reclaim leftover .tmp-* download staging dirs from an interrupted download. Runs
|
||||
// once here (before any webview) so it can never race a live download.
|
||||
zookeeper::sweep_versions_tmp(app.handle());
|
||||
|
||||
if let Err(err) = ensure_toolbar_window(&app.handle()) {
|
||||
log::warn!("failed to prepare toolbar window: {}", err);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ import { mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { UploadIcon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { onErrorDialog } from '../../lib/errors'
|
||||
import { getConfigPath } from '../../lib/rclone/common'
|
||||
import { useHostStore } from '../../store/host'
|
||||
import type { ConfigFile } from '../../types/config'
|
||||
@@ -85,14 +86,11 @@ export default function ConfigCreateDrawer({
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('[createConfig] failed to save config', error)
|
||||
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
onError: onErrorDialog('Failed to save config', undefined, {
|
||||
okLabel: 'OK',
|
||||
})
|
||||
},
|
||||
capture: false,
|
||||
log: ['[createConfig] failed to save config'],
|
||||
}),
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,6 +15,7 @@ import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { onErrorDialog } from '../../lib/errors'
|
||||
import { getConfigPath } from '../../lib/rclone/common'
|
||||
import { useHostStore } from '../../store/host'
|
||||
|
||||
@@ -78,14 +79,11 @@ export default function ConfigEditDrawer({
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('[updateConfig] failed to save config', error)
|
||||
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
onError: onErrorDialog('Failed to save config', undefined, {
|
||||
okLabel: 'OK',
|
||||
})
|
||||
},
|
||||
capture: false,
|
||||
log: ['[updateConfig] failed to save config'],
|
||||
}),
|
||||
})
|
||||
|
||||
const initializeConfig = useCallback(async () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import { exists, readTextFile } from '@tauri-apps/plugin-fs'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { UploadIcon } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { onErrorDialog } from '../../lib/errors'
|
||||
import { useHostStore } from '../../store/host'
|
||||
import type { ConfigFile } from '../../types/config'
|
||||
|
||||
@@ -70,14 +71,11 @@ export default function ConfigSyncDrawer({
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('[createSyncConfig] failed to save config', error)
|
||||
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
onError: onErrorDialog('Failed to save config', undefined, {
|
||||
okLabel: 'OK',
|
||||
})
|
||||
},
|
||||
capture: false,
|
||||
log: ['[createSyncConfig] failed to save config'],
|
||||
}),
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useMutation } from '@tanstack/react-query'
|
||||
import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { useState } from 'react'
|
||||
import { onErrorDialog } from '../../lib/errors'
|
||||
import { getHostInfo } from '../../lib/hosts'
|
||||
import { usePersistedStore } from '../../store/persisted'
|
||||
|
||||
@@ -83,13 +84,10 @@ export default function HostAddDrawer({
|
||||
setForm(INITIAL_FORM_STATE)
|
||||
onClose()
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('[addHost] failed', error)
|
||||
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
|
||||
title: 'Connection failed',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
onError: onErrorDialog('Connection failed', undefined, {
|
||||
capture: false,
|
||||
log: ['[addHost] failed'],
|
||||
}),
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -541,6 +541,9 @@ export default function OptionsSection({
|
||||
lineHeight: 20,
|
||||
paddingTop: 0,
|
||||
textareaOffsetTop: 0,
|
||||
// Tracked so textModel (which measures line wrapping) recomputes on horizontal resize —
|
||||
// the ResizeObserver refreshes this state, giving the memo a dep that follows the DOM.
|
||||
clientWidth: 0,
|
||||
})
|
||||
const [activeDecoration, setActiveDecoration] = useState<DecorationState | null>(null)
|
||||
const [isSelectorOpen, setIsSelectorOpen] = useState(false)
|
||||
@@ -576,12 +579,14 @@ export default function OptionsSection({
|
||||
const baseRect = textareaBaseRef.current?.getBoundingClientRect()
|
||||
const textareaRect = textarea.getBoundingClientRect()
|
||||
const textareaOffsetTop = baseRect ? textareaRect.top - baseRect.top : 0
|
||||
const clientWidth = textarea.clientWidth
|
||||
|
||||
setTextareaLayout((previous) => {
|
||||
if (
|
||||
previous.lineHeight === lineHeight &&
|
||||
previous.paddingTop === paddingTop &&
|
||||
previous.textareaOffsetTop === textareaOffsetTop
|
||||
previous.textareaOffsetTop === textareaOffsetTop &&
|
||||
previous.clientWidth === clientWidth
|
||||
) {
|
||||
return previous
|
||||
}
|
||||
@@ -590,6 +595,7 @@ export default function OptionsSection({
|
||||
lineHeight,
|
||||
paddingTop,
|
||||
textareaOffsetTop,
|
||||
clientWidth,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { message, open } from '@tauri-apps/plugin-dialog'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { FolderOpen } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useState } from 'react'
|
||||
import { onErrorDialog } from '../../lib/errors'
|
||||
import { useFlags } from '../../lib/hooks'
|
||||
import { lockWindows, unlockWindows } from '../../lib/window'
|
||||
import { type RemoteConfig, useHostStore } from '../../store/host'
|
||||
@@ -165,13 +166,10 @@ export default function RemoteAutoMountDrawer({
|
||||
setButtonText('Save Changes')
|
||||
}, 1200)
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Failed to update remote:', error)
|
||||
await message(error instanceof Error ? error.message : 'Unknown error occurred', {
|
||||
title: 'Could not update remote',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
onError: onErrorDialog('Could not update remote', 'Unknown error occurred', {
|
||||
capture: false,
|
||||
log: ['Failed to update remote:'],
|
||||
}),
|
||||
})
|
||||
|
||||
const setMountOnStart = useCallback(
|
||||
|
||||
@@ -109,6 +109,7 @@ export default function RemoteCreateDrawer({
|
||||
kind: 'error',
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
await message(errorMessage, {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Drawer, DrawerBody, DrawerContent, DrawerFooter, DrawerHeader, cn } from '@heroui/react'
|
||||
import { Button, Select, SelectItem } from '@heroui/react'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { onErrorDialog } from '../../lib/errors'
|
||||
import { useRemoteConfig } from '../../lib/hooks'
|
||||
import queryClient from '../../lib/query'
|
||||
import rclone from '../../lib/rclone/client'
|
||||
import { OVERRIDES } from '../../lib/rclone/overrides'
|
||||
@@ -22,18 +23,7 @@ export default function RemoteEditDrawer({
|
||||
const [config, setConfig] = useState<Record<string, any>>({})
|
||||
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||
|
||||
const remoteConfigQuery = useQuery({
|
||||
queryKey: ['remote', remoteName, 'config'],
|
||||
queryFn: async () => {
|
||||
return await rclone('/config/get', {
|
||||
params: {
|
||||
query: {
|
||||
name: remoteName,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
const remoteConfigQuery = useRemoteConfig(remoteName)
|
||||
|
||||
const remoteConfig = useMemo(() => remoteConfigQuery.data, [remoteConfigQuery.data])
|
||||
|
||||
@@ -111,7 +101,9 @@ export default function RemoteEditDrawer({
|
||||
return updatedRemoteConfig
|
||||
},
|
||||
onSuccess: async (updatedRemoteConfig) => {
|
||||
await rclone('/fscache/clear').catch()
|
||||
// Best-effort cache clear; a failure here must not reject onSuccess and leave the
|
||||
// drawer stranded open after an otherwise-successful save.
|
||||
await rclone('/fscache/clear').catch(() => null)
|
||||
queryClient.setQueryData(
|
||||
['remote', remoteName, 'config'],
|
||||
(old?: typeof remoteConfig) => ({
|
||||
@@ -121,13 +113,10 @@ export default function RemoteEditDrawer({
|
||||
)
|
||||
onClose()
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Failed to update remote:', error)
|
||||
await message(error instanceof Error ? error.message : 'Unknown error occurred', {
|
||||
title: 'Could not update remote',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
onError: onErrorDialog('Could not update remote', 'Unknown error occurred', {
|
||||
capture: false,
|
||||
log: ['Failed to update remote:'],
|
||||
}),
|
||||
})
|
||||
|
||||
// if (!remoteConfig) return null
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Tab, Tabs } from '@heroui/react'
|
||||
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { type Dispatch, type SetStateAction, useEffect, useMemo, useRef } from 'react'
|
||||
import { getRemoteName } from '../../lib/format'
|
||||
import { remoteConfigQueryOptions } from '../../lib/hooks'
|
||||
import rclone from '../../lib/rclone/client'
|
||||
import type { FlagValue } from '../../types/rclone'
|
||||
import OptionsSection from '../components/OptionsSection'
|
||||
|
||||
const IGNORED_OPTIONS = [
|
||||
@@ -25,22 +25,23 @@ const IGNORED_OPTIONS = [
|
||||
'sse_customer_key_md5',
|
||||
]
|
||||
|
||||
// Pure view over the remotes option state owned by useOptionGroups: each tab renders the raw
|
||||
// per-remote JSON doc and writes through; parsing/retention/reset semantics live in the hook.
|
||||
export default function RemoteOptionsSection({
|
||||
selectedRemotes,
|
||||
remoteOptionsLocked,
|
||||
remoteOptionsJsonString,
|
||||
setRemoteOptionsJsonString,
|
||||
remoteOptionsJson,
|
||||
setRemoteOptionsJson,
|
||||
reconcileRemotes,
|
||||
setRemoteOptionsLocked,
|
||||
}: {
|
||||
selectedRemotes: string[]
|
||||
remoteOptionsLocked: boolean
|
||||
remoteOptionsJsonString: string
|
||||
setRemoteOptionsJsonString: (value: string) => void
|
||||
remoteOptionsJson: Record<string, string>
|
||||
setRemoteOptionsJson: Dispatch<SetStateAction<Record<string, string>>>
|
||||
reconcileRemotes: (remoteNames: string[], force?: boolean) => void
|
||||
setRemoteOptionsLocked: (value: boolean) => void
|
||||
}) {
|
||||
const [optionsJsonStrings, setOptionsJsonStrings] = useState<Record<string, string>>({})
|
||||
const [options, setOptions] = useState<Record<string, Record<string, FlagValue[]>>>({})
|
||||
|
||||
const backendsQuery = useQuery({
|
||||
queryKey: ['backends'],
|
||||
queryFn: async () => {
|
||||
@@ -73,32 +74,16 @@ export default function RemoteOptionsSection({
|
||||
)
|
||||
|
||||
const remoteConfigQueries = useQueries({
|
||||
queries: uniqueRemotes.map((remote) => ({
|
||||
queryKey: ['remote', remote, 'config', 'withName'],
|
||||
queryFn: async () => {
|
||||
const remoteConfig = await rclone('/config/get', {
|
||||
params: {
|
||||
query: {
|
||||
name: remote,
|
||||
},
|
||||
},
|
||||
})
|
||||
return {
|
||||
name: remote,
|
||||
config: remoteConfig,
|
||||
}
|
||||
},
|
||||
})),
|
||||
queries: uniqueRemotes.map((remote) => remoteConfigQueryOptions(remote)),
|
||||
})
|
||||
|
||||
const remoteConfigs = useMemo(
|
||||
() =>
|
||||
remoteConfigQueries
|
||||
.map((query) => query.data)
|
||||
.map((query, i) => ({ name: uniqueRemotes[i], config: query.data }))
|
||||
.map((data) => {
|
||||
if (!data) return null
|
||||
|
||||
const { config, name } = data
|
||||
if (!config) return null
|
||||
|
||||
if (config.type === 's3') {
|
||||
if (config.provider) {
|
||||
@@ -149,81 +134,45 @@ export default function RemoteOptionsSection({
|
||||
}
|
||||
})
|
||||
.filter(Boolean),
|
||||
[remoteConfigQueries, backends]
|
||||
[remoteConfigQueries, backends, uniqueRemotes]
|
||||
)
|
||||
|
||||
// Report the current unique remote names so the hook can rebuild the tab strings when the
|
||||
// remote count changes (prune on deselect, seed on addition, discard mid-edit invalid text).
|
||||
// The first call after (re)mounting forces the rebuild: the old per-tab strings were child
|
||||
// state destroyed on unmount, so a remount always rebuilt every tab from the last-valid doc.
|
||||
const isFirstReconcile = useRef(true)
|
||||
useEffect(() => {
|
||||
console.log('[RemoteOptionsSection] optionsJsonStrings', optionsJsonStrings)
|
||||
}, [optionsJsonStrings])
|
||||
reconcileRemotes(uniqueRemotes, isFirstReconcile.current)
|
||||
isFirstReconcile.current = false
|
||||
}, [uniqueRemotes, reconcileRemotes])
|
||||
|
||||
useEffect(() => {
|
||||
if (Object.keys(optionsJsonStrings).length === uniqueRemotes.length) {
|
||||
console.log('[RemoteOptionsSection] optionsJsonStrings already set')
|
||||
return
|
||||
}
|
||||
console.log(
|
||||
'[RemoteOptionsSection] setting optionsJsonStrings, parsing remoteOptionsJsonString: ',
|
||||
remoteOptionsJsonString
|
||||
)
|
||||
const parsed = JSON.parse(remoteOptionsJsonString) as Record<string, string>
|
||||
console.log('[RemoteOptionsSection] setting optionsJsonStrings parsed', parsed)
|
||||
const jsonStrings: Record<string, string> = uniqueRemotes.reduce(
|
||||
(acc, curr) => {
|
||||
console.log('[RemoteOptionsSection] curr', curr)
|
||||
console.log('[RemoteOptionsSection] parsed[curr]', parsed[curr])
|
||||
acc[curr] = parsed[curr] ?? '{}'
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, string>
|
||||
)
|
||||
console.log('[RemoteOptionsSection] setting optionsJsonStrings to: ', jsonStrings)
|
||||
startTransition(() => {
|
||||
setOptionsJsonStrings(jsonStrings)
|
||||
})
|
||||
}, [uniqueRemotes, optionsJsonStrings, remoteOptionsJsonString])
|
||||
|
||||
useEffect(() => {
|
||||
const stringified = JSON.stringify(
|
||||
Object.entries(options).reduce(
|
||||
(acc, [r, o]) => {
|
||||
acc[r] = JSON.stringify(o, null, 2)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, string>
|
||||
)
|
||||
)
|
||||
console.log('[RemoteOptionsSection] stringified', stringified)
|
||||
startTransition(() => {
|
||||
setRemoteOptionsJsonString(stringified)
|
||||
})
|
||||
}, [options, setRemoteOptionsJsonString])
|
||||
|
||||
// OptionsSection calls setOptionsJson on every keystroke, including mid-edit
|
||||
// when the JSON is temporarily invalid. OptionsSection shows "Invalid JSON"
|
||||
// inline via its own isJsonValid state. The try/catch here just skips the
|
||||
// update so we keep the last valid parsed options until the user fixes the JSON.
|
||||
useEffect(() => {
|
||||
const newOptions: Record<string, Record<string, FlagValue[]>> = {}
|
||||
for (const [r, o] of Object.entries(optionsJsonStrings)) {
|
||||
try {
|
||||
newOptions[r] = JSON.parse(o) as Record<string, FlagValue[]>
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
startTransition(() => {
|
||||
setOptions(newOptions)
|
||||
})
|
||||
}, [optionsJsonStrings])
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
items={remoteConfigs.map((data) => ({
|
||||
const tabItems = useMemo(
|
||||
() =>
|
||||
remoteConfigs.map((data) => ({
|
||||
id: data.name,
|
||||
label: data.name.toUpperCase(),
|
||||
options: data.options,
|
||||
config: data.config,
|
||||
}))}
|
||||
})),
|
||||
[remoteConfigs]
|
||||
)
|
||||
|
||||
const setOptionsJsonByRemote = useMemo(() => {
|
||||
const map: Record<string, (json: string) => void> = {}
|
||||
for (const data of remoteConfigs) {
|
||||
map[data.name] = (json: string) =>
|
||||
setRemoteOptionsJson((prev) => ({
|
||||
...prev,
|
||||
[data.name]: json,
|
||||
}))
|
||||
}
|
||||
return map
|
||||
}, [remoteConfigs, setRemoteOptionsJson])
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
items={tabItems}
|
||||
fullWidth={true}
|
||||
variant="bordered"
|
||||
destroyInactiveTabPanel={false}
|
||||
@@ -232,13 +181,8 @@ export default function RemoteOptionsSection({
|
||||
{(item) => (
|
||||
<Tab key={item.id} title={item.label}>
|
||||
<OptionsSection
|
||||
optionsJson={optionsJsonStrings[item.id]}
|
||||
setOptionsJson={(json) =>
|
||||
setOptionsJsonStrings((prev) => ({
|
||||
...prev,
|
||||
[item.id]: json,
|
||||
}))
|
||||
}
|
||||
optionsJson={remoteOptionsJson[item.id] ?? '{}'}
|
||||
setOptionsJson={setOptionsJsonByRemote[item.id]}
|
||||
globalOptions={item.config}
|
||||
availableOptions={item.options}
|
||||
isLocked={remoteOptionsLocked}
|
||||
|
||||
@@ -17,6 +17,7 @@ import { format } from 'date-fns'
|
||||
import { CalendarClockIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { buildReadablePath } from '../../lib/format'
|
||||
import { useNow } from '../../lib/hooks'
|
||||
import { useHostStore } from '../../store/host'
|
||||
import type { ScheduledTask } from '../../types/schedules'
|
||||
import CronEditor from './CronEditor'
|
||||
@@ -51,9 +52,15 @@ export default function ScheduleEditDrawer({
|
||||
[selectedTask.args]
|
||||
)
|
||||
|
||||
// The drawer stays mounted after close, so "the next 5 runs" must be re-anchored to the
|
||||
// current time on every open (and kept fresh while open) — paused while closed.
|
||||
const now = useNow(isOpen ? 30_000 : null)
|
||||
|
||||
const upcomingRuns = useMemo(() => {
|
||||
try {
|
||||
const parsed = CronExpressionParser.parse(cronExpression)
|
||||
const parsed = CronExpressionParser.parse(cronExpression, {
|
||||
currentDate: new Date(now),
|
||||
})
|
||||
const runs: Date[] = []
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (parsed.hasNext()) {
|
||||
@@ -64,7 +71,7 @@ export default function ScheduleEditDrawer({
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}, [cronExpression])
|
||||
}, [cronExpression, now])
|
||||
|
||||
const hasChanges = useMemo(
|
||||
() => cronExpression !== selectedTask.cron,
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
import { formatErrorMessage } from '../../lib/errors'
|
||||
import {
|
||||
FLAG_CATEGORIES,
|
||||
getJsonKeyCount,
|
||||
@@ -147,9 +148,7 @@ export default function TemplateAddDrawer({
|
||||
},
|
||||
onError: async (error) => {
|
||||
await message(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Error saving template. Please check your options.',
|
||||
formatErrorMessage(error, 'Error saving template. Please check your options.'),
|
||||
{
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
WrenchIcon,
|
||||
} from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { formatErrorMessage } from '../../lib/errors'
|
||||
import {
|
||||
FLAG_CATEGORIES,
|
||||
getJsonKeyCount,
|
||||
@@ -170,9 +171,7 @@ export default function TemplateEditDrawer({
|
||||
},
|
||||
onError: async (error) => {
|
||||
await message(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Error saving template. Please check your options.',
|
||||
formatErrorMessage(error, 'Error saving template. Please check your options.'),
|
||||
{
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { remoteConfigQueryOptions } from '../../../lib/hooks'
|
||||
import { supportsPublicLink } from '../../../lib/rclone/constants'
|
||||
import rclone from '../../../lib/rclone/client'
|
||||
import { useHostStore } from '../../../store/host.ts'
|
||||
import FileList from './FileList'
|
||||
import PanelToolbar, { type ToolbarButtons } from './PanelToolbar'
|
||||
@@ -90,12 +90,7 @@ const FilePanel = forwardRef<
|
||||
})
|
||||
|
||||
const remoteConfigQuery = useQuery({
|
||||
queryKey: ['remote', nav.selectedRemote, 'config'],
|
||||
queryFn: async () => {
|
||||
return await rclone('/config/get', {
|
||||
params: { query: { name: nav.selectedRemote! } },
|
||||
})
|
||||
},
|
||||
...remoteConfigQueryOptions(nav.selectedRemote),
|
||||
enabled: nav.isRemote,
|
||||
})
|
||||
|
||||
@@ -181,12 +176,13 @@ const FilePanel = forwardRef<
|
||||
}
|
||||
}, [onDrop, nav.selectedRemote, nav.cwd])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: <>
|
||||
// getSelection is useCallback'd on [selectedPaths], so its identity alone tracks selection
|
||||
// changes — no extra dep or suppression needed.
|
||||
useEffect(() => {
|
||||
if (onSelectionChange) {
|
||||
onSelectionChange(nav.getSelection())
|
||||
}
|
||||
}, [nav.selectedPaths, onSelectionChange, nav.getSelection])
|
||||
}, [onSelectionChange, nav.getSelection])
|
||||
|
||||
useEffect(() => {
|
||||
if (onNavigate && nav.selectedRemote) {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Button, Input, Tooltip, cn } from '@heroui/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { CheckIcon, ChevronRightIcon, LaptopIcon, PencilIcon, StarIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import rclone from '../../../lib/rclone/client.ts'
|
||||
import { useRemoteConfig } from '../../../lib/hooks'
|
||||
import type { RemoteString } from './types'
|
||||
import { getPathSegments } from './utils'
|
||||
|
||||
@@ -25,16 +24,7 @@ export default function PathBreadcrumb({
|
||||
const [isInputMode, setIsInputMode] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const remoteConfigQuery = useQuery({
|
||||
queryKey: ['remote', remote, 'config'],
|
||||
queryFn: async () => {
|
||||
if (!remote || remote === 'UI_LOCAL_FS' || remote === 'UI_FAVORITES') return null
|
||||
return await rclone('/config/get', {
|
||||
params: { query: { name: remote } },
|
||||
})
|
||||
},
|
||||
enabled: !!remote && remote !== 'UI_LOCAL_FS' && remote !== 'UI_FAVORITES',
|
||||
})
|
||||
const remoteConfigQuery = useRemoteConfig(remote)
|
||||
|
||||
const remoteType = remoteConfigQuery.data?.type
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import { DownloadIcon, FileIcon as FileIconLucide, XIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { formatBytes } from '../../../lib/format.ts'
|
||||
import { usePersistedStore } from '../../../store/persisted.ts'
|
||||
import { useCurrentHost } from '../../../store/persisted.ts'
|
||||
import FileIcon, { getFileType, isPreviewable } from './FileIcon'
|
||||
import type { Entry } from './types'
|
||||
|
||||
@@ -44,7 +44,7 @@ export default function PreviewDrawer({
|
||||
item: Entry | null
|
||||
onClose: () => void
|
||||
}) {
|
||||
const currentHost = usePersistedStore((state) => state.currentHost)
|
||||
const currentHost = useCurrentHost()
|
||||
const hostUrl = currentHost?.url
|
||||
const authUser = currentHost?.authUser
|
||||
const authPassword = currentHost?.authPassword
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { StarIcon } from 'lucide-react'
|
||||
import { useMemo } from 'react'
|
||||
import { useRemoteConfig } from '../../../lib/hooks'
|
||||
import rclone from '../../../lib/rclone/client.ts'
|
||||
import type { AllowedKey, RemoteString } from './types'
|
||||
import { getDiskIcon, getDiskLabel, shouldShowDisk } from './utils'
|
||||
@@ -16,18 +17,7 @@ function RemoteButton({
|
||||
onSelect: (remote: string) => void
|
||||
isSelected: boolean
|
||||
}) {
|
||||
const remoteConfigQuery = useQuery({
|
||||
queryKey: ['remote', remote, 'config'],
|
||||
queryFn: async () => {
|
||||
return await rclone('/config/get', {
|
||||
params: {
|
||||
query: {
|
||||
name: remote,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
const remoteConfigQuery = useRemoteConfig(remote)
|
||||
|
||||
const info = remoteConfigQuery.data ?? null
|
||||
|
||||
|
||||
@@ -1,27 +1,16 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { reportError } from '../../../lib/errors'
|
||||
import { getFsInfo } from '../../../lib/format'
|
||||
import { useRemoteConfig } from '../../../lib/hooks'
|
||||
import rclone from '../../../lib/rclone/client'
|
||||
import { supportsPersistentEmptyFolders } from '../../../lib/rclone/constants'
|
||||
import type { RemoteString } from './types'
|
||||
import { RE_TRAILING_SEPARATORS } from './utils'
|
||||
|
||||
export default function useCreateFolder(
|
||||
remote: RemoteString,
|
||||
cwd: string,
|
||||
refresh: () => void
|
||||
) {
|
||||
const remoteConfigQuery = useQuery({
|
||||
queryKey: ['remote', remote, 'config'],
|
||||
queryFn: async () => {
|
||||
return await rclone('/config/get', {
|
||||
params: { query: { name: remote! } },
|
||||
})
|
||||
},
|
||||
enabled: !!remote && remote !== 'UI_LOCAL_FS' && remote !== 'UI_FAVORITES',
|
||||
})
|
||||
export default function useCreateFolder(remote: RemoteString, cwd: string, refresh: () => void) {
|
||||
const remoteConfigQuery = useRemoteConfig(remote)
|
||||
|
||||
const backendType = useMemo(() => {
|
||||
if (!remote || remote === 'UI_FAVORITES') return null
|
||||
@@ -74,9 +63,10 @@ export default function useCreateFolder(
|
||||
|
||||
refresh()
|
||||
} catch (error) {
|
||||
await message(error instanceof Error ? error.message : 'Create folder failed', {
|
||||
await reportError(error, {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
fallback: 'Create folder failed',
|
||||
capture: false,
|
||||
})
|
||||
}
|
||||
}, [remote, cwd, refresh, canCreateFolder])
|
||||
|
||||
@@ -319,14 +319,26 @@ export default function useFileNavigation({
|
||||
setRefreshKey((k) => k + 1)
|
||||
}, [selectedRemote, cwd])
|
||||
|
||||
// Initialize on first mount if no initial values provided
|
||||
// Initialize once per activation. The guard is set inside the branches (the remotes branch
|
||||
// only once the list has loaded, so late data can still finish the job) — after that, dep
|
||||
// churn (e.g. a /config/listremotes refetch minting a new `remotes` identity) can no longer
|
||||
// yank live navigation back to the initial location. Deliberately no effect cleanup:
|
||||
// cancelling the pending homeDir() write would strand the panel on isLoading.
|
||||
const hasInitializedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
if (!isActive) {
|
||||
// Deactivation re-arms initialization so a closed-and-reopened drawer (PathSelector
|
||||
// passes isActive={isOpen}) still resets to its initial location.
|
||||
hasInitializedRef.current = false
|
||||
return
|
||||
}
|
||||
if (hasInitializedRef.current) return
|
||||
|
||||
const hasInitial = initialRemote !== undefined
|
||||
const needsLocalPath = initialRemote === 'UI_LOCAL_FS' && !initialPath
|
||||
|
||||
if (needsLocalPath || (!hasInitial && canShowLocal)) {
|
||||
hasInitializedRef.current = true
|
||||
setIsLoading(true)
|
||||
homeDir().then((home) => {
|
||||
startTransition(() => {
|
||||
@@ -337,13 +349,23 @@ export default function useFileNavigation({
|
||||
setIsLoading(false)
|
||||
})
|
||||
} else if (!hasInitial && canShowFavorites) {
|
||||
hasInitializedRef.current = true
|
||||
startTransition(() => setSelectedRemote('UI_FAVORITES'))
|
||||
} else if (!hasInitial && canShowRemotes && remotes.length > 0) {
|
||||
} else if (!hasInitial && canShowRemotes) {
|
||||
// remotes still loading (empty list): stay uninitialized so the arrival re-run
|
||||
// completes the initialization.
|
||||
if (remotes.length > 0) {
|
||||
hasInitializedRef.current = true
|
||||
startTransition(() => {
|
||||
setSelectedRemote(remotes[0])
|
||||
setCwd('')
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// hasInitial with a concrete remote/path: state was already seeded by the useState
|
||||
// initializers; nothing to apply.
|
||||
hasInitializedRef.current = true
|
||||
}
|
||||
}, [
|
||||
isActive,
|
||||
canShowLocal,
|
||||
@@ -355,6 +377,7 @@ export default function useFileNavigation({
|
||||
])
|
||||
|
||||
// Load directory content when remote/cwd changes
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey is an intentional re-run trigger the body doesn't read — refresh() evicts the cacheRef entry, clears items, and bumps it to force a refetch of the current directory; removing it breaks the Refresh button (empty panel, isLoading stuck true)
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger,
|
||||
Tooltip,
|
||||
} from '@heroui/react'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ClockIcon, EyeIcon } from 'lucide-react'
|
||||
import { type ComponentProps, type ReactNode, useCallback, useMemo } from 'react'
|
||||
import { openWindow } from '../../../lib/window'
|
||||
import type { Template } from '../../../types/template'
|
||||
import CommandInfoButton from '../CommandInfoButton'
|
||||
import CommandsDropdown from '../CommandsDropdown'
|
||||
import TemplatesDropdown from '../TemplatesDropdown'
|
||||
|
||||
/**
|
||||
* The footer strip shared by the operation pages: TemplatesDropdown wiring, the AnimatePresence
|
||||
* START/NEW swap with the three reset items, and the ButtonGroup (dry-run when the page has one,
|
||||
* schedule, help, commands). Everything page-specific stays page-supplied: the reset onPress
|
||||
* bodies, the start/dry-run gating condition (`startBlocked` — the page's jsonError + path
|
||||
* checks), button text/icon, the NEW label, and the help prose.
|
||||
*/
|
||||
export default function OperationFooter({
|
||||
operation,
|
||||
templatesDisabled,
|
||||
onTemplateSelect,
|
||||
getTemplateOptions,
|
||||
startIsSuccess,
|
||||
startIsPending,
|
||||
onStart,
|
||||
onSchedule,
|
||||
dryRunIsPending,
|
||||
onDryRun,
|
||||
startBlocked,
|
||||
buttonText,
|
||||
buttonIcon,
|
||||
newLabel,
|
||||
newButtonPrimary = true,
|
||||
showViewTransfers = true,
|
||||
resetPathsLabel = 'Reset Paths',
|
||||
onResetPaths,
|
||||
onResetOptions,
|
||||
onResetAll,
|
||||
helpContent,
|
||||
}: {
|
||||
operation: Template['tags'][number]
|
||||
templatesDisabled: boolean
|
||||
onTemplateSelect: ComponentProps<typeof TemplatesDropdown>['onSelect']
|
||||
getTemplateOptions: ComponentProps<typeof TemplatesDropdown>['getOptions']
|
||||
startIsSuccess: boolean
|
||||
startIsPending: boolean
|
||||
onStart: () => void
|
||||
onSchedule: () => void
|
||||
// Present only on pages with a dry-run mutation (Copy/Sync/Move/Delete).
|
||||
dryRunIsPending?: boolean
|
||||
onDryRun?: () => void
|
||||
startBlocked: boolean
|
||||
buttonText: string
|
||||
buttonIcon: ReactNode
|
||||
newLabel: string
|
||||
newButtonPrimary?: boolean
|
||||
showViewTransfers?: boolean
|
||||
resetPathsLabel?: string
|
||||
onResetPaths: () => void
|
||||
onResetOptions: () => void
|
||||
onResetAll: () => void
|
||||
helpContent: string
|
||||
}) {
|
||||
const dropdownShadow = useMemo(() => (platform() === 'windows' ? 'none' : undefined), [])
|
||||
|
||||
const handleStartPress = useCallback(() => {
|
||||
setTimeout(() => onStart(), 100)
|
||||
}, [onStart])
|
||||
|
||||
const handleDryRunPress = useCallback(() => {
|
||||
if (dryRunIsPending || startBlocked) {
|
||||
return
|
||||
}
|
||||
setTimeout(() => onDryRun?.(), 100)
|
||||
}, [dryRunIsPending, startBlocked, onDryRun])
|
||||
|
||||
const handleSchedulePress = useCallback(() => {
|
||||
setTimeout(() => onSchedule(), 100)
|
||||
}, [onSchedule])
|
||||
|
||||
const handleViewTransfersPress = useCallback(async () => {
|
||||
await openWindow({
|
||||
name: 'Transfers',
|
||||
url: '/transfers',
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TemplatesDropdown
|
||||
isDisabled={templatesDisabled}
|
||||
operation={operation}
|
||||
onSelect={onTemplateSelect}
|
||||
getOptions={getTemplateOptions}
|
||||
/>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{startIsSuccess ? (
|
||||
<motion.div
|
||||
key="started-buttons"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="flex flex-1 gap-2"
|
||||
>
|
||||
<Dropdown shadow={dropdownShadow}>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
fullWidth={true}
|
||||
size="lg"
|
||||
color={newButtonPrimary ? 'primary' : undefined}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
{newLabel}
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu>
|
||||
<DropdownItem key="reset-paths" onPress={onResetPaths}>
|
||||
{resetPathsLabel}
|
||||
</DropdownItem>
|
||||
<DropdownItem key="reset-options" onPress={onResetOptions}>
|
||||
Reset Options
|
||||
</DropdownItem>
|
||||
<DropdownItem key="reset-all" onPress={onResetAll}>
|
||||
Reset All
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
|
||||
{showViewTransfers ? (
|
||||
<Button
|
||||
fullWidth={true}
|
||||
size="lg"
|
||||
color="secondary"
|
||||
onPress={handleViewTransfersPress}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
VIEW TRANSFERS
|
||||
</Button>
|
||||
) : null}
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="start-button"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="flex flex-1"
|
||||
>
|
||||
<Button
|
||||
onPress={handleStartPress}
|
||||
size="lg"
|
||||
fullWidth={true}
|
||||
type="button"
|
||||
color="primary"
|
||||
isDisabled={startIsPending || startBlocked}
|
||||
isLoading={startIsPending}
|
||||
endContent={buttonIcon}
|
||||
className="max-w-2xl gap-2"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<ButtonGroup variant="flat">
|
||||
{onDryRun ? (
|
||||
<Tooltip
|
||||
content="Preview (Dry Run)"
|
||||
placement="top"
|
||||
size="lg"
|
||||
color="foreground"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
type="button"
|
||||
color="primary"
|
||||
isIconOnly={true}
|
||||
isLoading={dryRunIsPending}
|
||||
onPress={handleDryRunPress}
|
||||
>
|
||||
<EyeIcon className="size-6" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
<Tooltip content="Schedule task" placement="top" size="lg" color="foreground">
|
||||
<Button
|
||||
size="lg"
|
||||
type="button"
|
||||
color="primary"
|
||||
isIconOnly={true}
|
||||
onPress={handleSchedulePress}
|
||||
>
|
||||
<ClockIcon className="size-6" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<CommandInfoButton content={helpContent} />
|
||||
<CommandsDropdown currentCommand={operation} />
|
||||
</ButtonGroup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Accordion, AccordionItem, Avatar } from '@heroui/react'
|
||||
import {
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
DiamondPercentIcon,
|
||||
FilterIcon,
|
||||
FolderSyncIcon,
|
||||
MoveIcon,
|
||||
ServerIcon,
|
||||
WrenchIcon,
|
||||
} from 'lucide-react'
|
||||
import { type ComponentType, type ReactNode, useMemo } from 'react'
|
||||
import ShowMoreOptionsBanner from '../ShowMoreOptionsBanner'
|
||||
|
||||
// Avatar/indicator/title per option category — exactly what each page's accordion rendered.
|
||||
export const CATEGORY_META: Record<
|
||||
'copy' | 'sync' | 'move' | 'bisync' | 'filters' | 'cron' | 'config' | 'remotes',
|
||||
{
|
||||
title: string
|
||||
icon: ComponentType<{ className?: string }>
|
||||
avatarColor?: 'primary' | 'success' | 'danger' | 'warning' | 'default'
|
||||
avatarClassName?: string
|
||||
avatarIconClassName?: string
|
||||
}
|
||||
> = {
|
||||
copy: { title: 'Copy', icon: CopyIcon, avatarColor: 'primary' },
|
||||
sync: { title: 'Sync', icon: FolderSyncIcon, avatarColor: 'success' },
|
||||
move: { title: 'Move', icon: MoveIcon, avatarColor: 'primary' },
|
||||
bisync: {
|
||||
title: 'Bisync',
|
||||
icon: DiamondPercentIcon,
|
||||
avatarClassName: 'bg-lime-500',
|
||||
avatarIconClassName: 'text-success-foreground',
|
||||
},
|
||||
filters: { title: 'Filters', icon: FilterIcon, avatarColor: 'danger' },
|
||||
cron: { title: 'Cron', icon: ClockIcon, avatarColor: 'warning' },
|
||||
config: { title: 'Config', icon: WrenchIcon, avatarColor: 'default' },
|
||||
remotes: { title: 'Remotes', icon: ServerIcon, avatarClassName: 'bg-fuchsia-500' },
|
||||
}
|
||||
|
||||
export type OptionCategory = keyof typeof CATEGORY_META
|
||||
|
||||
export interface OptionsAccordionItemDef {
|
||||
key: string
|
||||
category: OptionCategory
|
||||
subtitle?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* The option-group accordion shared by the operation pages: item scaffolding (Avatar,
|
||||
* indicator, title) comes from CATEGORY_META; each item's content (OptionsSection /
|
||||
* CronEditor / RemoteOptionsSection) stays page-supplied. `banner` wraps the accordion in the
|
||||
* relative div with the ShowMoreOptionsBanner (Copy/Sync/Move); Bisync/Delete/Purge omit it.
|
||||
*/
|
||||
export default function OptionsAccordion({
|
||||
items,
|
||||
defaultExpandedKeys,
|
||||
banner = false,
|
||||
}: {
|
||||
items: OptionsAccordionItemDef[]
|
||||
defaultExpandedKeys?: string[]
|
||||
banner?: boolean
|
||||
}) {
|
||||
const accordionItems = useMemo(
|
||||
() =>
|
||||
items.map((item) => {
|
||||
const meta = CATEGORY_META[item.category]
|
||||
const Icon = meta.icon
|
||||
return (
|
||||
<AccordionItem
|
||||
key={item.key}
|
||||
startContent={
|
||||
<Avatar
|
||||
color={meta.avatarColor}
|
||||
className={meta.avatarClassName}
|
||||
radius="lg"
|
||||
fallback={<Icon className={meta.avatarIconClassName} />}
|
||||
/>
|
||||
}
|
||||
indicator={<Icon />}
|
||||
title={meta.title}
|
||||
subtitle={item.subtitle}
|
||||
>
|
||||
{item.children}
|
||||
</AccordionItem>
|
||||
)
|
||||
}),
|
||||
[items]
|
||||
)
|
||||
|
||||
const accordion = (
|
||||
<Accordion
|
||||
keepContentMounted={true}
|
||||
dividerProps={{
|
||||
className: 'opacity-50',
|
||||
}}
|
||||
defaultExpandedKeys={defaultExpandedKeys}
|
||||
>
|
||||
{accordionItems}
|
||||
</Accordion>
|
||||
)
|
||||
|
||||
if (!banner) {
|
||||
return accordion
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col">
|
||||
{accordion}
|
||||
<ShowMoreOptionsBanner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { ask } from '@tauri-apps/plugin-dialog'
|
||||
import { onErrorDialog } from '../../../lib/errors'
|
||||
import { openWindow } from '../../../lib/window'
|
||||
|
||||
/**
|
||||
* The dry-run mutation shared by the operation pages that offer one (Copy/Sync/Move/Delete).
|
||||
* The page supplies the whole mutationFn — including its path validation and the per-page
|
||||
* `config: { ...configOptions, dry_run: true }` merge, which must stay in the page so no page
|
||||
* can silently lose the dry_run injection.
|
||||
*/
|
||||
export function useOperationDryRun(mutationFn: () => Promise<unknown>) {
|
||||
return useMutation({
|
||||
mutationFn,
|
||||
onSuccess: async () => {
|
||||
const result = await ask(
|
||||
'Dry run started, you can check the results in the Transfers screen',
|
||||
{
|
||||
title: 'Preview (Dry Run)',
|
||||
kind: 'info',
|
||||
okLabel: 'Open Transfers',
|
||||
cancelLabel: 'OK',
|
||||
}
|
||||
)
|
||||
if (result) {
|
||||
await openWindow({ name: 'Transfers', url: '/transfers' })
|
||||
}
|
||||
},
|
||||
onError: onErrorDialog('Dry Run', 'Failed to start dry run', {
|
||||
capture: false,
|
||||
log: ['Error starting dry run:'],
|
||||
}),
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -64,9 +64,9 @@ if (
|
||||
|
||||
// placed here to avoid circular dependency
|
||||
usePersistedStore.subscribe(async (state, prevState) => {
|
||||
if (state.currentHost?.id !== prevState.currentHost?.id && state.currentHost?.id) {
|
||||
console.log('[Store] Host changed to', state.currentHost.id)
|
||||
await initHostStore(state.currentHost.id).catch(console.error)
|
||||
if (state.currentHostId !== prevState.currentHostId && state.currentHostId) {
|
||||
console.log('[Store] Host changed to', state.currentHostId)
|
||||
await initHostStore(state.currentHostId).catch(console.error)
|
||||
await queryClient.cancelQueries()
|
||||
clearClient()
|
||||
queryClient.clear()
|
||||
|
||||
+254
-503
@@ -1,51 +1,71 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger,
|
||||
Switch,
|
||||
Tooltip,
|
||||
} from '@heroui/react'
|
||||
import { Switch } from '@heroui/react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import cronstrue from 'cronstrue'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
AlertOctagonIcon,
|
||||
ClockIcon,
|
||||
DiamondPercentIcon,
|
||||
FilterIcon,
|
||||
FoldersIcon,
|
||||
PlayIcon,
|
||||
ServerIcon,
|
||||
WrenchIcon,
|
||||
} from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
|
||||
import { startTransition, useCallback, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { onErrorDialog } from '../../lib/errors'
|
||||
import { getOptionsSubtitle } from '../../lib/flags'
|
||||
import { useFlags } from '../../lib/hooks'
|
||||
import notify from '../../lib/notify'
|
||||
import { startBisync } from '../../lib/rclone/api'
|
||||
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
|
||||
import { openWindow } from '../../lib/window'
|
||||
import { useHostStore } from '../../store/host'
|
||||
import type { FlagValue } from '../../types/rclone'
|
||||
import CommandInfoButton from '../components/CommandInfoButton'
|
||||
import CommandsDropdown from '../components/CommandsDropdown'
|
||||
import CronEditor from '../components/CronEditor'
|
||||
import OperationWindowContent from '../components/OperationWindowContent'
|
||||
import OperationWindowFooter from '../components/OperationWindowFooter'
|
||||
import OptionsSection from '../components/OptionsSection'
|
||||
import { PathFinder } from '../components/PathFinder'
|
||||
import RemoteOptionsSection from '../components/RemoteOptionsSection'
|
||||
import TemplatesDropdown from '../components/TemplatesDropdown'
|
||||
import OperationFooter from '../components/operation/OperationFooter'
|
||||
import OptionsAccordion, {
|
||||
type OptionsAccordionItemDef,
|
||||
} from '../components/operation/OptionsAccordion'
|
||||
import { useOptionGroups } from '../components/operation/useOptionGroups'
|
||||
import { useScheduleTask } from '../components/operation/useScheduleTask'
|
||||
|
||||
const HELP_CONTENT = `Performs bidirectional synchronization between two paths.
|
||||
|
||||
Bisync keeps both Path1 and Path2 in sync by propagating changes in both directions. On each run, it compares the current state to the previous run and detects New, Newer, Older, and Deleted files on each side, then propagates those changes to the other path.
|
||||
|
||||
Bisync retains the filesystem listings from the prior run. This history allows it to determine what has changed since the last sync. If something evil happens, bisync goes into a safe state to block damage by later runs — you may need to run with resync to recover.
|
||||
|
||||
This is an advanced command — use with care. Unlike Copy or Sync which have a clear "source of truth", Bisync must resolve conflicts when both sides have changed. When a file changes on both sides and the versions differ, bisync will rename both versions as conflicts (e.g., file.conflict1, file.conflict2) so nothing is lost. Make sure you understand the behavior before using on important data.
|
||||
|
||||
If you only need one-way synchronization (making destination match source), use the SYNC command instead.
|
||||
|
||||
Here's a quick guide to using the Bisync command:
|
||||
|
||||
1. SELECT PATHS
|
||||
Use the path selectors at the top to choose Path1 and Path2. Both paths will be kept in sync with each other — there is no "source" or "destination", changes flow both ways.
|
||||
|
||||
2. CONFIGURE OPTIONS (Optional)
|
||||
Expand the accordion sections to customize your bisync operation. The Bisync section has important switches at the top:
|
||||
|
||||
• resync — Required for the first run, or to reset bisync after an error. This makes both paths contain a matching superset of all files by copying Path2 to Path1, then Path1 to Path2. Only use resync when starting fresh, after changing filter settings, or recovering from an error — using it routinely would prevent deletions from syncing (deleted files would keep reappearing from the other side).
|
||||
|
||||
• checkAccess — Safety check that looks for matching RCLONE_TEST files on both paths before syncing. You must first create these files yourself in both paths. This prevents data loss if a path is temporarily unavailable or mounted incorrectly.
|
||||
|
||||
• force — Override safety checks like max-delete protection. Use with caution, as this bypasses safeguards designed to prevent accidental mass deletions.
|
||||
|
||||
• createEmptySrcDirs — Sync empty directories as well as files. Without this, only files are synced and empty directories are ignored.
|
||||
|
||||
• removeEmptyDirs — Remove directories that become empty after syncing. Not compatible with createEmptySrcDirs — use one or the other.
|
||||
|
||||
• ignoreListingChecksum — Skip checksum retrieval when creating file listings, which can speed things up considerably on backends where hashes must be computed on the fly (like local). Note this only affects listing comparisons, not the actual sync operations.
|
||||
|
||||
• resilient — Allow bisync to retry on the next run after certain errors, instead of requiring a resync. Useful for running bisync as a scheduled background process. Combine with --recover and --max-lock for a robust "set-it-and-forget-it" setup.
|
||||
|
||||
• noCleanup — Don't delete temporary working files after the operation. Useful for debugging issues, but normally you should leave this off.
|
||||
|
||||
3. OTHER OPTIONS
|
||||
Tap any chip on the right to add it to the JSON editor. Hover over chips to see what each option does.
|
||||
|
||||
• Filters — Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age).
|
||||
|
||||
• Config — Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes.
|
||||
|
||||
• Remotes — Override backend-specific settings for remotes involved in this operation.
|
||||
|
||||
4. START BISYNC
|
||||
Once paths are selected, tap "START BISYNC" to begin. For your first run, make sure "resync" is enabled to establish the initial baseline. You can monitor progress on the Transfers page.`
|
||||
|
||||
export default function Bisync() {
|
||||
const [searchParams] = useSearchParams()
|
||||
@@ -58,166 +78,87 @@ export default function Bisync() {
|
||||
searchParams.get('initialDestination') ? searchParams.get('initialDestination')! : undefined
|
||||
)
|
||||
|
||||
const [jsonError, setJsonError] = useState<'bisync' | 'filter' | 'config' | 'remote' | null>(
|
||||
null
|
||||
)
|
||||
const {
|
||||
jsonError,
|
||||
setJsonError,
|
||||
groups: optionGroups,
|
||||
remotes: remotesGroup,
|
||||
applyTemplate,
|
||||
getMergedOptions,
|
||||
resetJson,
|
||||
resetLocks,
|
||||
} = useOptionGroups({
|
||||
groups: [
|
||||
{ key: 'bisync', templateKey: 'copy', defaults: RCLONE_CONFIG_DEFAULTS.copy },
|
||||
{ key: 'filter' },
|
||||
{ key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config },
|
||||
],
|
||||
withRemotes: true,
|
||||
})
|
||||
const bisyncGroup = optionGroups.bisync
|
||||
const filterGroup = optionGroups.filter
|
||||
const configGroup = optionGroups.config
|
||||
|
||||
const [bisyncOptionsLocked, setBisyncOptionsLocked] = useState(false)
|
||||
const [bisyncOptions, setBisyncOptions] = useState<Record<string, FlagValue>>({})
|
||||
const [bisyncOptionsJsonString, setBisyncOptionsJsonString] = useState<string>('{}')
|
||||
const [outerBisyncOptions, setOuterBisyncOptions] = useState<Record<string, boolean>>({})
|
||||
|
||||
const [filterOptionsLocked, setFilterOptionsLocked] = useState(false)
|
||||
const [filterOptions, setFilterOptions] = useState<Record<string, FlagValue>>({})
|
||||
const [filterOptionsJsonString, setFilterOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const [configOptionsLocked, setConfigOptionsLocked] = useState(false)
|
||||
const [configOptions, setConfigOptions] = useState<Record<string, FlagValue>>({})
|
||||
const [configOptionsJsonString, setConfigOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const [remoteOptionsLocked, setRemoteOptionsLocked] = useState(false)
|
||||
const [remoteOptions, setRemoteOptions] = useState<Record<string, Record<string, FlagValue>>>(
|
||||
{}
|
||||
)
|
||||
const [remoteOptionsJsonString, setRemoteOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
|
||||
const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest])
|
||||
|
||||
const buildStartArgs = () => ({
|
||||
source: source!,
|
||||
destination: dest!,
|
||||
options: {
|
||||
config: configGroup.options,
|
||||
bisync: bisyncGroup.options,
|
||||
filter: filterGroup.options,
|
||||
remotes: remotesGroup.options,
|
||||
outer: outerBisyncOptions,
|
||||
},
|
||||
})
|
||||
|
||||
// The persisted schedule args deliberately omit the outer bisync switches — do not merge
|
||||
// this with buildStartArgs.
|
||||
const buildScheduleArgs = () => ({
|
||||
source: source!,
|
||||
destination: dest!,
|
||||
options: {
|
||||
config: configGroup.options,
|
||||
bisync: bisyncGroup.options,
|
||||
filter: filterGroup.options,
|
||||
remotes: remotesGroup.options,
|
||||
},
|
||||
})
|
||||
|
||||
const startBisyncMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!source || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
|
||||
return startBisync({
|
||||
source: source,
|
||||
destination: dest,
|
||||
options: {
|
||||
config: configOptions,
|
||||
bisync: bisyncOptions,
|
||||
filter: filterOptions,
|
||||
remotes: remoteOptions,
|
||||
outer: outerBisyncOptions,
|
||||
},
|
||||
})
|
||||
return startBisync(buildStartArgs())
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Error starting bisync:', error)
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Failed to start bisync operation'
|
||||
await message(errorMessage, {
|
||||
title: 'Bisync',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
onError: onErrorDialog('Bisync', 'Failed to start bisync operation', {
|
||||
capture: false,
|
||||
log: ['Error starting bisync:'],
|
||||
}),
|
||||
})
|
||||
|
||||
const scheduleTaskMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const scheduleTaskMutation = useScheduleTask({
|
||||
operation: 'bisync',
|
||||
cronExpression,
|
||||
validate: () => {
|
||||
if (!source || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
|
||||
if (!cronExpression) {
|
||||
throw new Error('Please enter a cron expression')
|
||||
}
|
||||
|
||||
try {
|
||||
cronstrue.toString(cronExpression)
|
||||
} catch {
|
||||
throw new Error('Invalid cron expression')
|
||||
}
|
||||
|
||||
const name = await invoke<string | null>('prompt', {
|
||||
title: 'Schedule Name',
|
||||
message: 'Enter a name for this schedule',
|
||||
default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`,
|
||||
})
|
||||
|
||||
if (!name) {
|
||||
throw new Error('Schedule name is required')
|
||||
}
|
||||
|
||||
useHostStore.getState().addScheduledTask({
|
||||
name,
|
||||
operation: 'bisync',
|
||||
cron: cronExpression,
|
||||
args: {
|
||||
source,
|
||||
destination: dest,
|
||||
options: {
|
||||
config: configOptions,
|
||||
bisync: bisyncOptions,
|
||||
filter: filterOptions,
|
||||
remotes: remoteOptions,
|
||||
},
|
||||
},
|
||||
buildArgs: buildScheduleArgs,
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await notify({
|
||||
title: 'Success',
|
||||
body: 'New schedule has been created',
|
||||
})
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Error scheduling task:', error)
|
||||
await message(error instanceof Error ? error.message : 'Failed to schedule task', {
|
||||
title: 'Schedule',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2))
|
||||
setBisyncOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.copy, null, 2))
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let step: 'bisync' | 'filter' | 'config' | 'remote' = 'bisync'
|
||||
try {
|
||||
const parsedBisync = JSON.parse(bisyncOptionsJsonString) as Record<string, FlagValue>
|
||||
|
||||
step = 'filter'
|
||||
const parsedFilter = JSON.parse(filterOptionsJsonString) as Record<string, FlagValue>
|
||||
|
||||
step = 'config'
|
||||
const parsedConfig = JSON.parse(configOptionsJsonString) as Record<string, FlagValue>
|
||||
|
||||
step = 'remote'
|
||||
const outerRemote = JSON.parse(remoteOptionsJsonString) as Record<string, string>
|
||||
const parsedRemote: Record<string, Record<string, FlagValue>> = {}
|
||||
for (const [key, val] of Object.entries(outerRemote)) {
|
||||
parsedRemote[key] = JSON.parse(val) as Record<string, FlagValue>
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setBisyncOptions(parsedBisync)
|
||||
setFilterOptions(parsedFilter)
|
||||
setConfigOptions(parsedConfig)
|
||||
setRemoteOptions(parsedRemote)
|
||||
setJsonError(null)
|
||||
})
|
||||
} catch (error) {
|
||||
setJsonError(step)
|
||||
console.error(`Error parsing ${step} options:`, error)
|
||||
}
|
||||
}, [
|
||||
bisyncOptionsJsonString,
|
||||
filterOptionsJsonString,
|
||||
configOptionsJsonString,
|
||||
remoteOptionsJsonString,
|
||||
])
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
if (startBisyncMutation.isPending) return 'STARTING...'
|
||||
@@ -236,39 +177,14 @@ export default function Bisync() {
|
||||
return <PlayIcon className="w-5 h-5 fill-current" />
|
||||
}, [startBisyncMutation.isPending, source, dest, jsonError])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10">
|
||||
{/* Main Content */}
|
||||
<OperationWindowContent>
|
||||
{/* Paths Display */}
|
||||
<PathFinder
|
||||
sourcePath={source}
|
||||
setSourcePath={setSource}
|
||||
destPath={dest}
|
||||
setDestPath={setDest}
|
||||
/>
|
||||
|
||||
<Accordion
|
||||
keepContentMounted={true}
|
||||
dividerProps={{
|
||||
className: 'opacity-50',
|
||||
}}
|
||||
>
|
||||
<AccordionItem
|
||||
key="bisync"
|
||||
startContent={
|
||||
<Avatar
|
||||
className="bg-lime-500"
|
||||
radius="lg"
|
||||
fallback={
|
||||
<DiamondPercentIcon className="text-success-foreground" />
|
||||
}
|
||||
/>
|
||||
}
|
||||
indicator={<DiamondPercentIcon />}
|
||||
title="Bisync"
|
||||
subtitle={getOptionsSubtitle(Object.keys(bisyncOptions).length)}
|
||||
>
|
||||
const accordionItems = useMemo<OptionsAccordionItemDef[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'bisync',
|
||||
category: 'bisync',
|
||||
subtitle: getOptionsSubtitle(Object.keys(bisyncGroup.options).length),
|
||||
children: (
|
||||
<>
|
||||
<div className="flex flex-row flex-wrap gap-2 pb-5">
|
||||
<Switch
|
||||
isSelected={outerBisyncOptions?.resync}
|
||||
@@ -376,327 +292,162 @@ export default function Bisync() {
|
||||
</div>
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.main || {}}
|
||||
optionsJson={bisyncOptionsJsonString}
|
||||
setOptionsJson={setBisyncOptionsJsonString}
|
||||
optionsJson={bisyncGroup.jsonString}
|
||||
setOptionsJson={bisyncGroup.setJsonString}
|
||||
availableOptions={copyFlags || []}
|
||||
isLocked={bisyncOptionsLocked}
|
||||
setIsLocked={setBisyncOptionsLocked}
|
||||
isLocked={bisyncGroup.locked}
|
||||
setIsLocked={bisyncGroup.setLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
key="filters"
|
||||
startContent={
|
||||
<Avatar color="danger" radius="lg" fallback={<FilterIcon />} />
|
||||
}
|
||||
indicator={<FilterIcon />}
|
||||
title="Filters"
|
||||
subtitle={getOptionsSubtitle(Object.keys(filterOptions).length)}
|
||||
>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'filters',
|
||||
category: 'filters',
|
||||
subtitle: getOptionsSubtitle(Object.keys(filterGroup.options).length),
|
||||
children: (
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.filter || {}}
|
||||
optionsJson={filterOptionsJsonString}
|
||||
setOptionsJson={setFilterOptionsJsonString}
|
||||
optionsJson={filterGroup.jsonString}
|
||||
setOptionsJson={filterGroup.setJsonString}
|
||||
availableOptions={filterFlags || []}
|
||||
isLocked={filterOptionsLocked}
|
||||
setIsLocked={setFilterOptionsLocked}
|
||||
isLocked={filterGroup.locked}
|
||||
setIsLocked={filterGroup.setLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
key="cron"
|
||||
startContent={
|
||||
<Avatar color="warning" radius="lg" fallback={<ClockIcon />} />
|
||||
}
|
||||
indicator={<ClockIcon />}
|
||||
title="Cron"
|
||||
>
|
||||
<CronEditor expression={cronExpression} onChange={setCronExpression} />
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
key="config"
|
||||
startContent={
|
||||
<Avatar color="default" radius="lg" fallback={<WrenchIcon />} />
|
||||
}
|
||||
indicator={<WrenchIcon />}
|
||||
title="Config"
|
||||
subtitle={getOptionsSubtitle(Object.keys(configOptions).length)}
|
||||
>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cron',
|
||||
category: 'cron',
|
||||
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
category: 'config',
|
||||
subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length),
|
||||
children: (
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.main || {}}
|
||||
optionsJson={configOptionsJsonString}
|
||||
setOptionsJson={setConfigOptionsJsonString}
|
||||
optionsJson={configGroup.jsonString}
|
||||
setOptionsJson={configGroup.setJsonString}
|
||||
availableOptions={configFlags || []}
|
||||
isLocked={configOptionsLocked}
|
||||
setIsLocked={setConfigOptionsLocked}
|
||||
isLocked={configGroup.locked}
|
||||
setIsLocked={configGroup.setLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
|
||||
{selectedRemotes.length > 0 ? (
|
||||
<AccordionItem
|
||||
key={'remotes'}
|
||||
startContent={
|
||||
<Avatar
|
||||
className="bg-fuchsia-500"
|
||||
radius="lg"
|
||||
fallback={<ServerIcon />}
|
||||
/>
|
||||
}
|
||||
indicator={<ServerIcon />}
|
||||
title={'Remotes'}
|
||||
subtitle={getOptionsSubtitle(
|
||||
Object.values(remoteOptions).reduce(
|
||||
),
|
||||
},
|
||||
...(selectedRemotes.length > 0
|
||||
? [
|
||||
{
|
||||
key: 'remotes',
|
||||
category: 'remotes' as const,
|
||||
subtitle: getOptionsSubtitle(
|
||||
Object.values(remotesGroup.options).reduce(
|
||||
(acc, opts) => acc + Object.keys(opts).length,
|
||||
0
|
||||
)
|
||||
)}
|
||||
>
|
||||
),
|
||||
children: (
|
||||
<RemoteOptionsSection
|
||||
selectedRemotes={selectedRemotes}
|
||||
remoteOptionsJsonString={remoteOptionsJsonString}
|
||||
setRemoteOptionsJsonString={setRemoteOptionsJsonString}
|
||||
setRemoteOptionsLocked={setRemoteOptionsLocked}
|
||||
remoteOptionsLocked={remoteOptionsLocked}
|
||||
remoteOptionsJson={remotesGroup.json}
|
||||
setRemoteOptionsJson={remotesGroup.setJson}
|
||||
reconcileRemotes={remotesGroup.reconcile}
|
||||
setRemoteOptionsLocked={remotesGroup.setLocked}
|
||||
remoteOptionsLocked={remotesGroup.locked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
) : null}
|
||||
</Accordion>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[
|
||||
bisyncGroup,
|
||||
outerBisyncOptions,
|
||||
globalFlags,
|
||||
copyFlags,
|
||||
filterGroup,
|
||||
filterFlags,
|
||||
cronExpression,
|
||||
configGroup,
|
||||
configFlags,
|
||||
selectedRemotes,
|
||||
remotesGroup,
|
||||
]
|
||||
)
|
||||
|
||||
const handleStart = useCallback(
|
||||
() => startBisyncMutation.mutate(),
|
||||
[startBisyncMutation.mutate]
|
||||
)
|
||||
|
||||
const handleSchedule = useCallback(
|
||||
() => scheduleTaskMutation.mutate(),
|
||||
[scheduleTaskMutation.mutate]
|
||||
)
|
||||
|
||||
const handleResetPaths = useCallback(() => {
|
||||
startTransition(() => {
|
||||
setSource(undefined)
|
||||
setDest(undefined)
|
||||
setJsonError(null)
|
||||
startBisyncMutation.reset()
|
||||
})
|
||||
}, [setJsonError, startBisyncMutation.reset])
|
||||
|
||||
const handleResetOptions = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
setOuterBisyncOptions({})
|
||||
startBisyncMutation.reset()
|
||||
})
|
||||
}, [resetJson, startBisyncMutation.reset])
|
||||
|
||||
const handleResetAll = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
resetLocks()
|
||||
setOuterBisyncOptions({})
|
||||
setSource(undefined)
|
||||
setDest(undefined)
|
||||
startBisyncMutation.reset()
|
||||
})
|
||||
}, [resetJson, resetLocks, startBisyncMutation.reset])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10">
|
||||
{/* Main Content */}
|
||||
<OperationWindowContent>
|
||||
{/* Paths Display */}
|
||||
<PathFinder
|
||||
sourcePath={source}
|
||||
setSourcePath={setSource}
|
||||
destPath={dest}
|
||||
setDestPath={setDest}
|
||||
/>
|
||||
|
||||
<OptionsAccordion items={accordionItems} />
|
||||
</OperationWindowContent>
|
||||
|
||||
<OperationWindowFooter>
|
||||
<TemplatesDropdown
|
||||
isDisabled={!!jsonError}
|
||||
<OperationFooter
|
||||
operation="bisync"
|
||||
onSelect={(groupedOptions, shouldMerge) => {
|
||||
startTransition(() => {
|
||||
if (shouldMerge) {
|
||||
if (groupedOptions.copy)
|
||||
setBisyncOptionsJsonString(JSON.stringify({ ...bisyncOptions, ...groupedOptions.copy }, null, 2))
|
||||
if (groupedOptions.filter)
|
||||
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2))
|
||||
if (groupedOptions.config)
|
||||
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2))
|
||||
} else {
|
||||
if (groupedOptions.copy) setBisyncOptionsJsonString(JSON.stringify(groupedOptions.copy, null, 2))
|
||||
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2))
|
||||
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2))
|
||||
}
|
||||
})
|
||||
}}
|
||||
getOptions={() => ({
|
||||
...bisyncOptions,
|
||||
...filterOptions,
|
||||
...configOptions,
|
||||
})}
|
||||
templatesDisabled={!!jsonError}
|
||||
onTemplateSelect={applyTemplate}
|
||||
getTemplateOptions={getMergedOptions}
|
||||
startIsSuccess={startBisyncMutation.isSuccess}
|
||||
startIsPending={startBisyncMutation.isPending}
|
||||
onStart={handleStart}
|
||||
onSchedule={handleSchedule}
|
||||
startBlocked={!!jsonError || !source || !dest || source === dest}
|
||||
buttonText={buttonText}
|
||||
buttonIcon={buttonIcon}
|
||||
newLabel="NEW BISYNC"
|
||||
onResetPaths={handleResetPaths}
|
||||
onResetOptions={handleResetOptions}
|
||||
onResetAll={handleResetAll}
|
||||
helpContent={HELP_CONTENT}
|
||||
/>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{startBisyncMutation.isSuccess ? (
|
||||
<motion.div
|
||||
key="started-buttons"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="flex flex-1 gap-2"
|
||||
>
|
||||
<Dropdown shadow={platform() === 'windows' ? 'none' : undefined}>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
fullWidth={true}
|
||||
color="primary"
|
||||
size="lg"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
NEW BISYNC
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu>
|
||||
<DropdownItem
|
||||
key="reset-paths"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setSource(undefined)
|
||||
setDest(undefined)
|
||||
setJsonError(null)
|
||||
startBisyncMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset Paths
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="reset-options"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setBisyncOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.copy,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setFilterOptionsJsonString('{}')
|
||||
setConfigOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.config,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setRemoteOptionsJsonString('{}')
|
||||
setOuterBisyncOptions({})
|
||||
setJsonError(null)
|
||||
startBisyncMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset Options
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="reset-all"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setBisyncOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.copy,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setFilterOptionsJsonString('{}')
|
||||
setConfigOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.config,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setRemoteOptionsJsonString('{}')
|
||||
setOuterBisyncOptions({})
|
||||
setBisyncOptionsLocked(false)
|
||||
setFilterOptionsLocked(false)
|
||||
setConfigOptionsLocked(false)
|
||||
setRemoteOptionsLocked(false)
|
||||
setJsonError(null)
|
||||
setSource(undefined)
|
||||
setDest(undefined)
|
||||
startBisyncMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset All
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
|
||||
<Button
|
||||
fullWidth={true}
|
||||
size="lg"
|
||||
color="secondary"
|
||||
onPress={async () => {
|
||||
await openWindow({
|
||||
name: 'Transfers',
|
||||
url: '/transfers',
|
||||
})
|
||||
}}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
VIEW TRANSFERS
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="start-button"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="flex flex-1"
|
||||
>
|
||||
<Button
|
||||
onPress={() => setTimeout(() => startBisyncMutation.mutate(), 100)}
|
||||
size="lg"
|
||||
fullWidth={true}
|
||||
type="button"
|
||||
color="primary"
|
||||
isDisabled={
|
||||
startBisyncMutation.isPending ||
|
||||
!!jsonError ||
|
||||
!source ||
|
||||
!dest ||
|
||||
source === dest
|
||||
}
|
||||
isLoading={startBisyncMutation.isPending}
|
||||
endContent={buttonIcon}
|
||||
className="max-w-2xl gap-2"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<ButtonGroup variant="flat">
|
||||
<Tooltip content={'Schedule task'} placement="top" size="lg" color="foreground">
|
||||
<Button
|
||||
size="lg"
|
||||
type="button"
|
||||
color="primary"
|
||||
isIconOnly={true}
|
||||
onPress={() => {
|
||||
setTimeout(() => scheduleTaskMutation.mutate(), 100)
|
||||
}}
|
||||
>
|
||||
<ClockIcon className="size-6" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<CommandInfoButton
|
||||
content={`Performs bidirectional synchronization between two paths.
|
||||
|
||||
Bisync keeps both Path1 and Path2 in sync by propagating changes in both directions. On each run, it compares the current state to the previous run and detects New, Newer, Older, and Deleted files on each side, then propagates those changes to the other path.
|
||||
|
||||
Bisync retains the filesystem listings from the prior run. This history allows it to determine what has changed since the last sync. If something evil happens, bisync goes into a safe state to block damage by later runs — you may need to run with resync to recover.
|
||||
|
||||
This is an advanced command — use with care. Unlike Copy or Sync which have a clear "source of truth", Bisync must resolve conflicts when both sides have changed. When a file changes on both sides and the versions differ, bisync will rename both versions as conflicts (e.g., file.conflict1, file.conflict2) so nothing is lost. Make sure you understand the behavior before using on important data.
|
||||
|
||||
If you only need one-way synchronization (making destination match source), use the SYNC command instead.
|
||||
|
||||
Here's a quick guide to using the Bisync command:
|
||||
|
||||
1. SELECT PATHS
|
||||
Use the path selectors at the top to choose Path1 and Path2. Both paths will be kept in sync with each other — there is no "source" or "destination", changes flow both ways.
|
||||
|
||||
2. CONFIGURE OPTIONS (Optional)
|
||||
Expand the accordion sections to customize your bisync operation. The Bisync section has important switches at the top:
|
||||
|
||||
• resync — Required for the first run, or to reset bisync after an error. This makes both paths contain a matching superset of all files by copying Path2 to Path1, then Path1 to Path2. Only use resync when starting fresh, after changing filter settings, or recovering from an error — using it routinely would prevent deletions from syncing (deleted files would keep reappearing from the other side).
|
||||
|
||||
• checkAccess — Safety check that looks for matching RCLONE_TEST files on both paths before syncing. You must first create these files yourself in both paths. This prevents data loss if a path is temporarily unavailable or mounted incorrectly.
|
||||
|
||||
• force — Override safety checks like max-delete protection. Use with caution, as this bypasses safeguards designed to prevent accidental mass deletions.
|
||||
|
||||
• createEmptySrcDirs — Sync empty directories as well as files. Without this, only files are synced and empty directories are ignored.
|
||||
|
||||
• removeEmptyDirs — Remove directories that become empty after syncing. Not compatible with createEmptySrcDirs — use one or the other.
|
||||
|
||||
• ignoreListingChecksum — Skip checksum retrieval when creating file listings, which can speed things up considerably on backends where hashes must be computed on the fly (like local). Note this only affects listing comparisons, not the actual sync operations.
|
||||
|
||||
• resilient — Allow bisync to retry on the next run after certain errors, instead of requiring a resync. Useful for running bisync as a scheduled background process. Combine with --recover and --max-lock for a robust "set-it-and-forget-it" setup.
|
||||
|
||||
• noCleanup — Don't delete temporary working files after the operation. Useful for debugging issues, but normally you should leave this off.
|
||||
|
||||
3. OTHER OPTIONS
|
||||
Tap any chip on the right to add it to the JSON editor. Hover over chips to see what each option does.
|
||||
|
||||
• Filters — Include or exclude files by pattern, limit by size (max_size, min_size) or age (max_age, min_age).
|
||||
|
||||
• Config — Performance tuning: parallel transfers, checkers, buffer_size, bandwidth limits (bwlimit), and fast_list for faster directory listings on supported remotes.
|
||||
|
||||
• Remotes — Override backend-specific settings for remotes involved in this operation.
|
||||
|
||||
4. START BISYNC
|
||||
Once paths are selected, tap "START BISYNC" to begin. For your first run, make sure "resync" is enabled to establish the initial baseline. You can monitor progress on the Transfers page.`}
|
||||
/>
|
||||
<CommandsDropdown currentCommand="bisync" />
|
||||
</ButtonGroup>
|
||||
</OperationWindowFooter>
|
||||
</div>
|
||||
)
|
||||
|
||||
+17
-25
@@ -19,7 +19,7 @@ import {
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
|
||||
import { ask, message, save } from '@tauri-apps/plugin-dialog'
|
||||
import { ask, save } from '@tauri-apps/plugin-dialog'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Group, Panel, Separator } from 'react-resizable-panels'
|
||||
import { onErrorDialog, reportError } from '../../lib/errors'
|
||||
import { getFsInfo } from '../../lib/format'
|
||||
// import { Document, Page, pdfjs } from 'react-pdf'
|
||||
import { formatBytes } from '../../lib/format.ts'
|
||||
@@ -123,9 +124,10 @@ export default function Browser() {
|
||||
const jobId = result?.jobid
|
||||
if (jobId) handleJobStarted(jobId)
|
||||
} catch (error) {
|
||||
await message(error instanceof Error ? error.message : 'Download failed', {
|
||||
await reportError(error, {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
fallback: 'Download failed',
|
||||
capture: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
@@ -160,9 +162,10 @@ export default function Browser() {
|
||||
leftPanelRef.current?.refresh()
|
||||
rightPanelRef.current?.refresh()
|
||||
} catch (error) {
|
||||
await message(error instanceof Error ? error.message : 'Delete failed', {
|
||||
await reportError(error, {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
fallback: 'Delete failed',
|
||||
capture: false,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
@@ -209,9 +212,10 @@ export default function Browser() {
|
||||
leftPanelRef.current?.refresh()
|
||||
rightPanelRef.current?.refresh()
|
||||
} catch (error) {
|
||||
await message(error instanceof Error ? error.message : 'Rename failed', {
|
||||
await reportError(error, {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
fallback: 'Rename failed',
|
||||
capture: false,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
@@ -235,14 +239,12 @@ export default function Browser() {
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
await message(
|
||||
error instanceof Error ? error.message : 'Failed to generate public link',
|
||||
{
|
||||
await reportError(error, {
|
||||
title: 'Share Error',
|
||||
kind: 'error',
|
||||
fallback: 'Failed to generate public link',
|
||||
okLabel: 'OK',
|
||||
}
|
||||
)
|
||||
capture: false,
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -616,12 +618,7 @@ function OperationDialog({
|
||||
onComplete?.()
|
||||
onClose()
|
||||
},
|
||||
onError: async (error) => {
|
||||
await message(error instanceof Error ? error.message : 'Copy operation failed', {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
onError: onErrorDialog('Error', 'Copy operation failed', { capture: false }),
|
||||
})
|
||||
|
||||
const moveMutation = useMutation({
|
||||
@@ -645,12 +642,7 @@ function OperationDialog({
|
||||
onComplete?.()
|
||||
onClose()
|
||||
},
|
||||
onError: async (error) => {
|
||||
await message(error instanceof Error ? error.message : 'Move operation failed', {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
onError: onErrorDialog('Error', 'Move operation failed', { capture: false }),
|
||||
})
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
|
||||
+307
-614
@@ -1,628 +1,28 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger,
|
||||
Tooltip,
|
||||
} from '@heroui/react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { ask, message } from '@tauri-apps/plugin-dialog'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import cronstrue from 'cronstrue'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
AlertOctagonIcon,
|
||||
ClockIcon,
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
FilterIcon,
|
||||
FoldersIcon,
|
||||
PlayIcon,
|
||||
ServerIcon,
|
||||
WrenchIcon,
|
||||
} from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
|
||||
import { startTransition, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { onErrorDialog } from '../../lib/errors'
|
||||
import { getOptionsSubtitle } from '../../lib/flags'
|
||||
import { useFlags } from '../../lib/hooks'
|
||||
import notify from '../../lib/notify'
|
||||
import { startCopy, startDryRun } from '../../lib/rclone/api'
|
||||
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
|
||||
import { openWindow } from '../../lib/window'
|
||||
import { useHostStore } from '../../store/host'
|
||||
import { usePersistedStore } from '../../store/persisted'
|
||||
import type { FlagValue } from '../../types/rclone'
|
||||
import CommandInfoButton from '../components/CommandInfoButton'
|
||||
import CommandsDropdown from '../components/CommandsDropdown'
|
||||
import CronEditor from '../components/CronEditor'
|
||||
import OperationWindowContent from '../components/OperationWindowContent'
|
||||
import OperationWindowFooter from '../components/OperationWindowFooter'
|
||||
import OptionsSection from '../components/OptionsSection'
|
||||
import { MultiPathFinder } from '../components/PathFinder'
|
||||
import RemoteOptionsSection from '../components/RemoteOptionsSection'
|
||||
import ShowMoreOptionsBanner from '../components/ShowMoreOptionsBanner'
|
||||
import TemplatesDropdown from '../components/TemplatesDropdown'
|
||||
import OperationFooter from '../components/operation/OperationFooter'
|
||||
import OptionsAccordion, {
|
||||
type OptionsAccordionItemDef,
|
||||
} from '../components/operation/OptionsAccordion'
|
||||
import { useOperationDryRun } from '../components/operation/useOperationDryRun'
|
||||
import { useOptionGroups } from '../components/operation/useOptionGroups'
|
||||
import { useScheduleTask } from '../components/operation/useScheduleTask'
|
||||
|
||||
export default function Copy() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { globalFlags, filterFlags, configFlags, copyFlags } = useFlags()
|
||||
|
||||
const [sources, setSources] = useState<string[] | undefined>(
|
||||
searchParams.get('initialSource') ? [searchParams.get('initialSource')!] : undefined
|
||||
)
|
||||
const [dest, setDest] = useState<string | undefined>(
|
||||
searchParams.get('initialDest') ? searchParams.get('initialDest')! : undefined
|
||||
)
|
||||
|
||||
const [jsonError, setJsonError] = useState<'copy' | 'filter' | 'config' | 'remote' | null>(null)
|
||||
|
||||
const [copyOptionsLocked, setCopyOptionsLocked] = useState(false)
|
||||
const [copyOptions, setCopyOptions] = useState<Record<string, FlagValue>>({})
|
||||
const [copyOptionsJsonString, setCopyOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const [filterOptionsLocked, setFilterOptionsLocked] = useState(false)
|
||||
const [filterOptions, setFilterOptions] = useState<Record<string, FlagValue>>({})
|
||||
const [filterOptionsJsonString, setFilterOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const [configOptionsLocked, setConfigOptionsLocked] = useState(false)
|
||||
const [configOptions, setConfigOptions] = useState<Record<string, FlagValue>>({})
|
||||
const [configOptionsJsonString, setConfigOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const [remoteOptionsLocked, setRemoteOptionsLocked] = useState(false)
|
||||
const [remoteOptions, setRemoteOptions] = useState<Record<string, Record<string, FlagValue>>>(
|
||||
{}
|
||||
)
|
||||
const [remoteOptionsJsonString, setRemoteOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
|
||||
const selectedRemotes = useMemo(
|
||||
() => [...(sources || []), dest].filter(Boolean),
|
||||
[sources, dest]
|
||||
)
|
||||
|
||||
const startCopyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!sources || sources.length === 0 || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
|
||||
return startCopy({
|
||||
sources,
|
||||
destination: dest,
|
||||
options: {
|
||||
config: configOptions,
|
||||
copy: copyOptions,
|
||||
filter: filterOptions,
|
||||
remotes: remoteOptions,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Error starting copy:', error)
|
||||
await message(error instanceof Error ? error.message : 'Failed to start copy', {
|
||||
title: 'Copy',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const scheduleTaskMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!sources || sources.length === 0 || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
|
||||
if (sources.length > 1 && !usePersistedStore.getState().licenseValid) {
|
||||
throw new Error('You need a valid license to schedule multiple tasks at once')
|
||||
}
|
||||
|
||||
if (!cronExpression) {
|
||||
throw new Error('Please enter a cron expression')
|
||||
}
|
||||
|
||||
try {
|
||||
cronstrue.toString(cronExpression)
|
||||
} catch {
|
||||
throw new Error('Invalid cron expression')
|
||||
}
|
||||
|
||||
const name = await invoke<string | null>('prompt', {
|
||||
title: 'Schedule Name',
|
||||
message: 'Enter a name for this schedule',
|
||||
default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`,
|
||||
})
|
||||
|
||||
if (!name) {
|
||||
throw new Error('Schedule name is required')
|
||||
}
|
||||
|
||||
useHostStore.getState().addScheduledTask({
|
||||
name,
|
||||
operation: 'copy',
|
||||
cron: cronExpression,
|
||||
args: {
|
||||
sources,
|
||||
destination: dest,
|
||||
options: {
|
||||
config: configOptions,
|
||||
copy: copyOptions,
|
||||
filter: filterOptions,
|
||||
remotes: remoteOptions,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await notify({
|
||||
title: 'Success',
|
||||
body: 'New schedule has been created',
|
||||
})
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Error scheduling task:', error)
|
||||
await message(error instanceof Error ? error.message : 'Failed to schedule task', {
|
||||
title: 'Schedule',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const dryRunMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!sources || sources.length === 0 || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
return startDryRun(() =>
|
||||
startCopy({
|
||||
sources,
|
||||
destination: dest,
|
||||
options: {
|
||||
config: { ...configOptions, dry_run: true },
|
||||
copy: copyOptions,
|
||||
filter: filterOptions,
|
||||
remotes: remoteOptions,
|
||||
},
|
||||
})
|
||||
)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
const result = await ask(
|
||||
'Dry run started, you can check the results in the Transfers screen',
|
||||
{
|
||||
title: 'Preview (Dry Run)',
|
||||
kind: 'info',
|
||||
okLabel: 'Open Transfers',
|
||||
cancelLabel: 'OK',
|
||||
}
|
||||
)
|
||||
if (result) {
|
||||
await openWindow({ name: 'Transfers', url: '/transfers' })
|
||||
}
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Error starting dry run:', error)
|
||||
await message(error instanceof Error ? error.message : 'Failed to start dry run', {
|
||||
title: 'Dry Run',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2))
|
||||
setCopyOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.copy, null, 2))
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let step: 'copy' | 'filter' | 'config' | 'remote' = 'copy'
|
||||
try {
|
||||
const parsedCopy = JSON.parse(copyOptionsJsonString) as Record<string, FlagValue>
|
||||
|
||||
step = 'filter'
|
||||
const parsedFilter = JSON.parse(filterOptionsJsonString) as Record<string, FlagValue>
|
||||
|
||||
step = 'config'
|
||||
const parsedConfig = JSON.parse(configOptionsJsonString) as Record<string, FlagValue>
|
||||
|
||||
step = 'remote'
|
||||
const outerRemote = JSON.parse(remoteOptionsJsonString) as Record<string, string>
|
||||
const parsedRemote: Record<string, Record<string, FlagValue>> = {}
|
||||
for (const [key, val] of Object.entries(outerRemote)) {
|
||||
parsedRemote[key] = JSON.parse(val) as Record<string, FlagValue>
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setCopyOptions(parsedCopy)
|
||||
setFilterOptions(parsedFilter)
|
||||
setConfigOptions(parsedConfig)
|
||||
setRemoteOptions(parsedRemote)
|
||||
setJsonError(null)
|
||||
})
|
||||
} catch (error) {
|
||||
setJsonError(step)
|
||||
console.error(`Error parsing ${step} options:`, error)
|
||||
}
|
||||
}, [
|
||||
copyOptionsJsonString,
|
||||
filterOptionsJsonString,
|
||||
configOptionsJsonString,
|
||||
remoteOptionsJsonString,
|
||||
])
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
if (startCopyMutation.isPending) return 'STARTING...'
|
||||
if (!sources || sources.length === 0) return 'Please select a source path'
|
||||
if (!dest) return 'Please select a destination path'
|
||||
if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
if (cronExpression) return 'START AND SCHEDULE COPY'
|
||||
return 'START COPY'
|
||||
}, [startCopyMutation.isPending, sources, dest, jsonError, cronExpression])
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (startCopyMutation.isPending) return
|
||||
if (!sources || sources.length === 0 || !dest || sources.some((s) => s === dest))
|
||||
return <FoldersIcon className="w-5 h-5" />
|
||||
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
|
||||
return <PlayIcon className="w-5 h-5 fill-current" />
|
||||
}, [startCopyMutation.isPending, sources, dest, jsonError])
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[Copy] remoteOptions', remoteOptions)
|
||||
console.log('[Copy] remoteOptionsJsonString', remoteOptionsJsonString)
|
||||
}, [remoteOptionsJsonString, remoteOptions])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10">
|
||||
{/* Main Content */}
|
||||
<OperationWindowContent>
|
||||
{/* Paths Display */}
|
||||
<MultiPathFinder
|
||||
sourcePaths={sources}
|
||||
setSourcePaths={setSources}
|
||||
destPath={dest}
|
||||
setDestPath={setDest}
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-col">
|
||||
<Accordion
|
||||
keepContentMounted={true}
|
||||
dividerProps={{
|
||||
className: 'opacity-50',
|
||||
}}
|
||||
>
|
||||
<AccordionItem
|
||||
key="copy"
|
||||
startContent={
|
||||
<Avatar color="primary" radius="lg" fallback={<CopyIcon />} />
|
||||
}
|
||||
indicator={<CopyIcon />}
|
||||
title="Copy"
|
||||
subtitle={getOptionsSubtitle(Object.keys(copyOptions).length)}
|
||||
>
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.main || {}}
|
||||
optionsJson={copyOptionsJsonString}
|
||||
setOptionsJson={setCopyOptionsJsonString}
|
||||
availableOptions={copyFlags || []}
|
||||
isLocked={copyOptionsLocked}
|
||||
setIsLocked={setCopyOptionsLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
key="filters"
|
||||
startContent={
|
||||
<Avatar color="danger" radius="lg" fallback={<FilterIcon />} />
|
||||
}
|
||||
indicator={<FilterIcon />}
|
||||
title="Filters"
|
||||
subtitle={getOptionsSubtitle(Object.keys(filterOptions).length)}
|
||||
>
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.filter || {}}
|
||||
optionsJson={filterOptionsJsonString}
|
||||
setOptionsJson={setFilterOptionsJsonString}
|
||||
availableOptions={filterFlags || []}
|
||||
isLocked={filterOptionsLocked}
|
||||
setIsLocked={setFilterOptionsLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
key="cron"
|
||||
startContent={
|
||||
<Avatar color="warning" radius="lg" fallback={<ClockIcon />} />
|
||||
}
|
||||
indicator={<ClockIcon />}
|
||||
title="Cron"
|
||||
>
|
||||
<CronEditor expression={cronExpression} onChange={setCronExpression} />
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
key="config"
|
||||
startContent={
|
||||
<Avatar color="default" radius="lg" fallback={<WrenchIcon />} />
|
||||
}
|
||||
indicator={<WrenchIcon />}
|
||||
title="Config"
|
||||
subtitle={getOptionsSubtitle(Object.keys(configOptions).length)}
|
||||
>
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.main || {}}
|
||||
optionsJson={configOptionsJsonString}
|
||||
setOptionsJson={setConfigOptionsJsonString}
|
||||
availableOptions={configFlags || []}
|
||||
isLocked={configOptionsLocked}
|
||||
setIsLocked={setConfigOptionsLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
|
||||
{selectedRemotes.length > 0 ? (
|
||||
<AccordionItem
|
||||
key={'remotes'}
|
||||
startContent={
|
||||
<Avatar
|
||||
className="bg-fuchsia-500"
|
||||
radius="lg"
|
||||
fallback={<ServerIcon />}
|
||||
/>
|
||||
}
|
||||
indicator={<ServerIcon />}
|
||||
title={'Remotes'}
|
||||
subtitle={getOptionsSubtitle(
|
||||
Object.values(remoteOptions).reduce(
|
||||
(acc, opts) => acc + Object.keys(opts).length,
|
||||
0
|
||||
)
|
||||
)}
|
||||
>
|
||||
<RemoteOptionsSection
|
||||
selectedRemotes={selectedRemotes}
|
||||
remoteOptionsJsonString={remoteOptionsJsonString}
|
||||
setRemoteOptionsJsonString={setRemoteOptionsJsonString}
|
||||
setRemoteOptionsLocked={setRemoteOptionsLocked}
|
||||
remoteOptionsLocked={remoteOptionsLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
) : null}
|
||||
</Accordion>
|
||||
|
||||
<ShowMoreOptionsBanner />
|
||||
</div>
|
||||
</OperationWindowContent>
|
||||
|
||||
<OperationWindowFooter>
|
||||
<TemplatesDropdown
|
||||
isDisabled={!!jsonError}
|
||||
operation="copy"
|
||||
onSelect={(groupedOptions, shouldMerge) => {
|
||||
startTransition(() => {
|
||||
if (shouldMerge) {
|
||||
if (groupedOptions.copy)
|
||||
setCopyOptionsJsonString(JSON.stringify({ ...copyOptions, ...groupedOptions.copy }, null, 2))
|
||||
if (groupedOptions.filter)
|
||||
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2))
|
||||
if (groupedOptions.config)
|
||||
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2))
|
||||
} else {
|
||||
if (groupedOptions.copy) setCopyOptionsJsonString(JSON.stringify(groupedOptions.copy, null, 2))
|
||||
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2))
|
||||
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2))
|
||||
}
|
||||
})
|
||||
}}
|
||||
getOptions={() => ({
|
||||
...copyOptions,
|
||||
...filterOptions,
|
||||
...configOptions,
|
||||
})}
|
||||
/>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{startCopyMutation.isSuccess ? (
|
||||
<motion.div
|
||||
key="started-buttons"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="flex flex-1 gap-2"
|
||||
>
|
||||
<Dropdown shadow={platform() === 'windows' ? 'none' : undefined}>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
fullWidth={true}
|
||||
size="lg"
|
||||
color="primary"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
NEW COPY
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu>
|
||||
<DropdownItem
|
||||
key="reset-paths"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setSources(undefined)
|
||||
setDest(undefined)
|
||||
setJsonError(null)
|
||||
startCopyMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset Paths
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="reset-options"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setCopyOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.copy,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setFilterOptionsJsonString('{}')
|
||||
setConfigOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.config,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setRemoteOptionsJsonString('{}')
|
||||
setCronExpression(null)
|
||||
setJsonError(null)
|
||||
startCopyMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset Options
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="reset-all"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setCopyOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.copy,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setFilterOptionsJsonString('{}')
|
||||
setConfigOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.config,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setRemoteOptionsJsonString('{}')
|
||||
setCopyOptionsLocked(false)
|
||||
setFilterOptionsLocked(false)
|
||||
setConfigOptionsLocked(false)
|
||||
setRemoteOptionsLocked(false)
|
||||
setCronExpression(null)
|
||||
setJsonError(null)
|
||||
setSources(undefined)
|
||||
setDest(undefined)
|
||||
startCopyMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset All
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
|
||||
<Button
|
||||
fullWidth={true}
|
||||
size="lg"
|
||||
color="secondary"
|
||||
onPress={async () => {
|
||||
await openWindow({
|
||||
name: 'Transfers',
|
||||
url: '/transfers',
|
||||
})
|
||||
}}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
VIEW TRANSFERS
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="start-button"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="flex flex-1"
|
||||
>
|
||||
<Button
|
||||
onPress={() => setTimeout(() => startCopyMutation.mutate(), 100)}
|
||||
size="lg"
|
||||
fullWidth={true}
|
||||
type="button"
|
||||
color="primary"
|
||||
isDisabled={
|
||||
startCopyMutation.isPending ||
|
||||
!!jsonError ||
|
||||
!sources ||
|
||||
sources.length === 0 ||
|
||||
!dest ||
|
||||
sources.some((s) => s === dest)
|
||||
}
|
||||
isLoading={startCopyMutation.isPending}
|
||||
endContent={buttonIcon}
|
||||
className="max-w-2xl gap-2"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<ButtonGroup variant="flat">
|
||||
<Tooltip
|
||||
content="Preview (Dry Run)"
|
||||
placement="top"
|
||||
size="lg"
|
||||
color="foreground"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
type="button"
|
||||
color="primary"
|
||||
isIconOnly={true}
|
||||
isLoading={dryRunMutation.isPending}
|
||||
onPress={() => {
|
||||
if (
|
||||
dryRunMutation.isPending ||
|
||||
!!jsonError ||
|
||||
!sources ||
|
||||
sources.length === 0 ||
|
||||
!dest ||
|
||||
sources.some((s) => s === dest)
|
||||
) {
|
||||
return
|
||||
}
|
||||
setTimeout(() => dryRunMutation.mutate(), 100)
|
||||
}}
|
||||
>
|
||||
<EyeIcon className="size-6" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Schedule task" placement="top" size="lg" color="foreground">
|
||||
<Button
|
||||
size="lg"
|
||||
type="button"
|
||||
color="primary"
|
||||
isIconOnly={true}
|
||||
onPress={() => {
|
||||
setTimeout(() => scheduleTaskMutation.mutate(), 100)
|
||||
}}
|
||||
>
|
||||
<ClockIcon className="size-6" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<CommandInfoButton
|
||||
content={`Copies the source(s) to the destination.
|
||||
const HELP_CONTENT = `Copies the source(s) to the destination.
|
||||
|
||||
Does not transfer files that are identical on source and destination (testing by size and modification time or MD5SUM). Does not delete files from the destination. If the destination path doesn't exist, it will be created automatically.
|
||||
|
||||
@@ -650,10 +50,303 @@ Expand the accordion sections to customize your copy operation. Tap any chip on
|
||||
Tap the folder icon in the bottom bar to load or save option presets. Templates let you quickly apply common configurations without manually setting each option.
|
||||
|
||||
4. START THE COPY
|
||||
Once paths are selected, tap "START COPY" to begin. You can monitor progress on the Transfers page.`}
|
||||
Once paths are selected, tap "START COPY" to begin. You can monitor progress on the Transfers page.`
|
||||
|
||||
export default function Copy() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { globalFlags, filterFlags, configFlags, copyFlags } = useFlags()
|
||||
|
||||
const [sources, setSources] = useState<string[] | undefined>(
|
||||
searchParams.get('initialSource') ? [searchParams.get('initialSource')!] : undefined
|
||||
)
|
||||
const [dest, setDest] = useState<string | undefined>(
|
||||
searchParams.get('initialDest') ? searchParams.get('initialDest')! : undefined
|
||||
)
|
||||
|
||||
const {
|
||||
jsonError,
|
||||
setJsonError,
|
||||
groups: optionGroups,
|
||||
remotes: remotesGroup,
|
||||
applyTemplate,
|
||||
getMergedOptions,
|
||||
resetJson,
|
||||
resetLocks,
|
||||
} = useOptionGroups({
|
||||
groups: [
|
||||
{ key: 'copy', defaults: RCLONE_CONFIG_DEFAULTS.copy },
|
||||
{ key: 'filter' },
|
||||
{ key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config },
|
||||
],
|
||||
withRemotes: true,
|
||||
})
|
||||
const copyGroup = optionGroups.copy
|
||||
const filterGroup = optionGroups.filter
|
||||
const configGroup = optionGroups.config
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
|
||||
const selectedRemotes = useMemo(
|
||||
() => [...(sources || []), dest].filter(Boolean),
|
||||
[sources, dest]
|
||||
)
|
||||
|
||||
const buildArgs = () => ({
|
||||
sources: sources!,
|
||||
destination: dest!,
|
||||
options: {
|
||||
config: configGroup.options,
|
||||
copy: copyGroup.options,
|
||||
filter: filterGroup.options,
|
||||
remotes: remotesGroup.options,
|
||||
},
|
||||
})
|
||||
|
||||
const startCopyMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!sources || sources.length === 0 || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
|
||||
return startCopy(buildArgs())
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
onError: onErrorDialog('Copy', 'Failed to start copy', {
|
||||
capture: false,
|
||||
log: ['Error starting copy:'],
|
||||
}),
|
||||
})
|
||||
|
||||
const scheduleTaskMutation = useScheduleTask({
|
||||
operation: 'copy',
|
||||
cronExpression,
|
||||
validate: () => {
|
||||
if (!sources || sources.length === 0 || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
|
||||
if (sources.length > 1 && !usePersistedStore.getState().licenseValid) {
|
||||
throw new Error('You need a valid license to schedule multiple tasks at once')
|
||||
}
|
||||
},
|
||||
buildArgs,
|
||||
})
|
||||
|
||||
const dryRunMutation = useOperationDryRun(async () => {
|
||||
if (!sources || sources.length === 0 || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
return startDryRun(() =>
|
||||
startCopy({
|
||||
sources,
|
||||
destination: dest,
|
||||
options: {
|
||||
config: { ...configGroup.options, dry_run: true },
|
||||
copy: copyGroup.options,
|
||||
filter: filterGroup.options,
|
||||
remotes: remotesGroup.options,
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
if (startCopyMutation.isPending) return 'STARTING...'
|
||||
if (!sources || sources.length === 0) return 'Please select a source path'
|
||||
if (!dest) return 'Please select a destination path'
|
||||
if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
if (cronExpression) return 'START AND SCHEDULE COPY'
|
||||
return 'START COPY'
|
||||
}, [startCopyMutation.isPending, sources, dest, jsonError, cronExpression])
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (startCopyMutation.isPending) return
|
||||
if (!sources || sources.length === 0 || !dest || sources.some((s) => s === dest))
|
||||
return <FoldersIcon className="w-5 h-5" />
|
||||
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
|
||||
return <PlayIcon className="w-5 h-5 fill-current" />
|
||||
}, [startCopyMutation.isPending, sources, dest, jsonError])
|
||||
|
||||
const accordionItems = useMemo<OptionsAccordionItemDef[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'copy',
|
||||
category: 'copy',
|
||||
subtitle: getOptionsSubtitle(Object.keys(copyGroup.options).length),
|
||||
children: (
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.main || {}}
|
||||
optionsJson={copyGroup.jsonString}
|
||||
setOptionsJson={copyGroup.setJsonString}
|
||||
availableOptions={copyFlags || []}
|
||||
isLocked={copyGroup.locked}
|
||||
setIsLocked={copyGroup.setLocked}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'filters',
|
||||
category: 'filters',
|
||||
subtitle: getOptionsSubtitle(Object.keys(filterGroup.options).length),
|
||||
children: (
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.filter || {}}
|
||||
optionsJson={filterGroup.jsonString}
|
||||
setOptionsJson={filterGroup.setJsonString}
|
||||
availableOptions={filterFlags || []}
|
||||
isLocked={filterGroup.locked}
|
||||
setIsLocked={filterGroup.setLocked}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cron',
|
||||
category: 'cron',
|
||||
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
category: 'config',
|
||||
subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length),
|
||||
children: (
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.main || {}}
|
||||
optionsJson={configGroup.jsonString}
|
||||
setOptionsJson={configGroup.setJsonString}
|
||||
availableOptions={configFlags || []}
|
||||
isLocked={configGroup.locked}
|
||||
setIsLocked={configGroup.setLocked}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(selectedRemotes.length > 0
|
||||
? [
|
||||
{
|
||||
key: 'remotes',
|
||||
category: 'remotes' as const,
|
||||
subtitle: getOptionsSubtitle(
|
||||
Object.values(remotesGroup.options).reduce(
|
||||
(acc, opts) => acc + Object.keys(opts).length,
|
||||
0
|
||||
)
|
||||
),
|
||||
children: (
|
||||
<RemoteOptionsSection
|
||||
selectedRemotes={selectedRemotes}
|
||||
remoteOptionsJson={remotesGroup.json}
|
||||
setRemoteOptionsJson={remotesGroup.setJson}
|
||||
reconcileRemotes={remotesGroup.reconcile}
|
||||
setRemoteOptionsLocked={remotesGroup.setLocked}
|
||||
remoteOptionsLocked={remotesGroup.locked}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[
|
||||
copyGroup,
|
||||
filterGroup,
|
||||
configGroup,
|
||||
remotesGroup,
|
||||
globalFlags,
|
||||
copyFlags,
|
||||
filterFlags,
|
||||
configFlags,
|
||||
cronExpression,
|
||||
selectedRemotes,
|
||||
]
|
||||
)
|
||||
|
||||
const handleStart = useCallback(() => startCopyMutation.mutate(), [startCopyMutation.mutate])
|
||||
|
||||
const handleSchedule = useCallback(
|
||||
() => scheduleTaskMutation.mutate(),
|
||||
[scheduleTaskMutation.mutate]
|
||||
)
|
||||
|
||||
const handleDryRun = useCallback(() => dryRunMutation.mutate(), [dryRunMutation.mutate])
|
||||
|
||||
const handleResetPaths = useCallback(() => {
|
||||
startTransition(() => {
|
||||
setSources(undefined)
|
||||
setDest(undefined)
|
||||
setJsonError(null)
|
||||
startCopyMutation.reset()
|
||||
})
|
||||
}, [setJsonError, startCopyMutation.reset])
|
||||
|
||||
const handleResetOptions = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
setCronExpression(null)
|
||||
startCopyMutation.reset()
|
||||
})
|
||||
}, [resetJson, startCopyMutation.reset])
|
||||
|
||||
const handleResetAll = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
resetLocks()
|
||||
setCronExpression(null)
|
||||
setSources(undefined)
|
||||
setDest(undefined)
|
||||
startCopyMutation.reset()
|
||||
})
|
||||
}, [resetJson, resetLocks, startCopyMutation.reset])
|
||||
|
||||
useEffect(() => {
|
||||
console.log('[Copy] remoteOptions', remotesGroup.options)
|
||||
console.log('[Copy] remoteOptionsJson', remotesGroup.json)
|
||||
}, [remotesGroup.json, remotesGroup.options])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10">
|
||||
{/* Main Content */}
|
||||
<OperationWindowContent>
|
||||
{/* Paths Display */}
|
||||
<MultiPathFinder
|
||||
sourcePaths={sources}
|
||||
setSourcePaths={setSources}
|
||||
destPath={dest}
|
||||
setDestPath={setDest}
|
||||
/>
|
||||
|
||||
<OptionsAccordion banner={true} items={accordionItems} />
|
||||
</OperationWindowContent>
|
||||
|
||||
<OperationWindowFooter>
|
||||
<OperationFooter
|
||||
operation="copy"
|
||||
templatesDisabled={!!jsonError}
|
||||
onTemplateSelect={applyTemplate}
|
||||
getTemplateOptions={getMergedOptions}
|
||||
startIsSuccess={startCopyMutation.isSuccess}
|
||||
startIsPending={startCopyMutation.isPending}
|
||||
onStart={handleStart}
|
||||
onSchedule={handleSchedule}
|
||||
dryRunIsPending={dryRunMutation.isPending}
|
||||
onDryRun={handleDryRun}
|
||||
startBlocked={
|
||||
!!jsonError ||
|
||||
!sources ||
|
||||
sources.length === 0 ||
|
||||
!dest ||
|
||||
sources.some((s) => s === dest)
|
||||
}
|
||||
buttonText={buttonText}
|
||||
buttonIcon={buttonIcon}
|
||||
newLabel="NEW COPY"
|
||||
onResetPaths={handleResetPaths}
|
||||
onResetOptions={handleResetOptions}
|
||||
onResetAll={handleResetAll}
|
||||
helpContent={HELP_CONTENT}
|
||||
/>
|
||||
<CommandsDropdown currentCommand="copy" />
|
||||
</ButtonGroup>
|
||||
</OperationWindowFooter>
|
||||
</div>
|
||||
)
|
||||
|
||||
+256
-498
@@ -1,511 +1,31 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
Alert,
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger,
|
||||
Tooltip,
|
||||
} from '@heroui/react'
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { ask, message } from '@tauri-apps/plugin-dialog'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import cronstrue from 'cronstrue'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
AlertOctagonIcon,
|
||||
ClockIcon,
|
||||
EyeIcon,
|
||||
FilterIcon,
|
||||
FoldersIcon,
|
||||
PlayIcon,
|
||||
WrenchIcon,
|
||||
} from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { Alert } from '@heroui/react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
|
||||
import { startTransition, useCallback, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { onErrorDialog } from '../../lib/errors'
|
||||
import { getOptionsSubtitle } from '../../lib/flags'
|
||||
import { getRemoteName } from '../../lib/format'
|
||||
import { useFlags } from '../../lib/hooks'
|
||||
import { useFlags, useRemoteConfig } from '../../lib/hooks'
|
||||
import notify from '../../lib/notify'
|
||||
import { startDelete, startDryRun } from '../../lib/rclone/api'
|
||||
import rclone from '../../lib/rclone/client'
|
||||
import { RCLONE_CONFIG_DEFAULTS, SUPPORTS_PURGE } from '../../lib/rclone/constants'
|
||||
import { openWindow } from '../../lib/window'
|
||||
import { useHostStore } from '../../store/host'
|
||||
import type { FlagValue } from '../../types/rclone'
|
||||
import CommandInfoButton from '../components/CommandInfoButton'
|
||||
import CommandsDropdown from '../components/CommandsDropdown'
|
||||
import CronEditor from '../components/CronEditor'
|
||||
import OperationWindowContent from '../components/OperationWindowContent'
|
||||
import OperationWindowFooter from '../components/OperationWindowFooter'
|
||||
import OptionsSection from '../components/OptionsSection'
|
||||
import { PathField } from '../components/PathFinder'
|
||||
import TemplatesDropdown from '../components/TemplatesDropdown'
|
||||
import OperationFooter from '../components/operation/OperationFooter'
|
||||
import OptionsAccordion, {
|
||||
type OptionsAccordionItemDef,
|
||||
} from '../components/operation/OptionsAccordion'
|
||||
import { useOperationDryRun } from '../components/operation/useOperationDryRun'
|
||||
import { useOptionGroups } from '../components/operation/useOptionGroups'
|
||||
import { useScheduleTask } from '../components/operation/useScheduleTask'
|
||||
|
||||
export default function Delete() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { globalFlags, filterFlags, configFlags } = useFlags()
|
||||
const PATH_ALLOWED_KEYS: ('LOCAL_FS' | 'FAVORITES' | 'REMOTES')[] = ['REMOTES', 'FAVORITES']
|
||||
|
||||
const [sourceFs, setSourceFs] = useState<string | undefined>(
|
||||
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
|
||||
)
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
|
||||
const [jsonError, setJsonError] = useState<'filter' | 'config' | null>(null)
|
||||
|
||||
const [filterOptionsLocked, setFilterOptionsLocked] = useState(false)
|
||||
const [filterOptions, setFilterOptions] = useState<Record<string, FlagValue>>({})
|
||||
const [filterOptionsJsonString, setFilterOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const [configOptionsLocked, setConfigOptionsLocked] = useState(false)
|
||||
const [configOptions, setConfigOptions] = useState<Record<string, FlagValue>>({})
|
||||
const [configOptionsJsonString, setConfigOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const sourceRemoteName = useMemo(() => getRemoteName(sourceFs), [sourceFs])
|
||||
|
||||
const sourceRemoteConfigQuery = useQuery({
|
||||
queryKey: ['remote', sourceRemoteName, 'config'],
|
||||
queryFn: async () => {
|
||||
return await rclone('/config/get', {
|
||||
params: {
|
||||
query: {
|
||||
name: sourceRemoteName!,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
enabled: !!sourceRemoteName,
|
||||
})
|
||||
|
||||
const supportsPurge = useMemo(
|
||||
() =>
|
||||
sourceRemoteConfigQuery.data
|
||||
? SUPPORTS_PURGE.includes(sourceRemoteConfigQuery.data.type)
|
||||
: false,
|
||||
[sourceRemoteConfigQuery.data]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2))
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let step: 'filter' | 'config' = 'filter'
|
||||
try {
|
||||
const parsedFilter = JSON.parse(filterOptionsJsonString) as Record<string, FlagValue>
|
||||
|
||||
step = 'config'
|
||||
const parsedConfig = JSON.parse(configOptionsJsonString) as Record<string, FlagValue>
|
||||
|
||||
startTransition(() => {
|
||||
setFilterOptions(parsedFilter)
|
||||
setConfigOptions(parsedConfig)
|
||||
setJsonError(null)
|
||||
})
|
||||
} catch (error) {
|
||||
setJsonError(step)
|
||||
console.error(`Error parsing ${step} options:`, error)
|
||||
}
|
||||
}, [filterOptionsJsonString, configOptionsJsonString])
|
||||
|
||||
const startDeleteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!sourceFs) {
|
||||
throw new Error('Please select a source path to delete')
|
||||
}
|
||||
|
||||
return startDelete({
|
||||
sources: [sourceFs],
|
||||
options: {
|
||||
filter: filterOptions,
|
||||
config: configOptions,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await notify({
|
||||
title: 'Success',
|
||||
body: 'Delete task started',
|
||||
})
|
||||
if (cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error starting delete:', error)
|
||||
Sentry.captureException(error)
|
||||
},
|
||||
})
|
||||
|
||||
const scheduleTaskMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!sourceFs) {
|
||||
throw new Error('Please select a source path to delete')
|
||||
}
|
||||
|
||||
if (!cronExpression) {
|
||||
throw new Error('Please enter a cron expression')
|
||||
}
|
||||
|
||||
try {
|
||||
cronstrue.toString(cronExpression)
|
||||
} catch {
|
||||
throw new Error('Invalid cron expression')
|
||||
}
|
||||
|
||||
const name = await invoke<string | null>('prompt', {
|
||||
title: 'Schedule Name',
|
||||
message: 'Enter a name for this schedule',
|
||||
default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`,
|
||||
})
|
||||
|
||||
if (!name) {
|
||||
throw new Error('Schedule name is required')
|
||||
}
|
||||
|
||||
useHostStore.getState().addScheduledTask({
|
||||
name,
|
||||
operation: 'delete',
|
||||
cron: cronExpression,
|
||||
args: {
|
||||
sources: [sourceFs],
|
||||
options: {
|
||||
filter: filterOptions,
|
||||
config: configOptions,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await notify({
|
||||
title: 'Success',
|
||||
body: 'New schedule has been created',
|
||||
})
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Error scheduling task:', error)
|
||||
await message(error instanceof Error ? error.message : 'Failed to schedule task', {
|
||||
title: 'Schedule',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const dryRunMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!sourceFs) {
|
||||
throw new Error('Please select a source path to delete')
|
||||
}
|
||||
return startDryRun(() =>
|
||||
startDelete({
|
||||
sources: [sourceFs],
|
||||
options: {
|
||||
filter: filterOptions,
|
||||
config: { ...configOptions, dry_run: true },
|
||||
},
|
||||
})
|
||||
)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
const result = await ask(
|
||||
'Dry run started, you can check the results in the Transfers screen',
|
||||
{
|
||||
title: 'Preview (Dry Run)',
|
||||
kind: 'info',
|
||||
okLabel: 'Open Transfers',
|
||||
cancelLabel: 'OK',
|
||||
}
|
||||
)
|
||||
if (result) {
|
||||
await openWindow({ name: 'Transfers', url: '/transfers' })
|
||||
}
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Error starting dry run:', error)
|
||||
await message(error instanceof Error ? error.message : 'Failed to start dry run', {
|
||||
title: 'Dry Run',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
if (startDeleteMutation.isPending) return 'STARTING...'
|
||||
if (!sourceFs || sourceFs.length === 0) return 'Please select a source path'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
if (cronExpression) return 'START AND SCHEDULE DELETE'
|
||||
return 'START DELETE'
|
||||
}, [startDeleteMutation.isPending, sourceFs, jsonError, cronExpression])
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (startDeleteMutation.isPending) return
|
||||
if (!sourceFs || sourceFs.length === 0) return <FoldersIcon className="w-5 h-5" />
|
||||
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
|
||||
return <PlayIcon className="w-5 h-5 fill-current" />
|
||||
}, [startDeleteMutation.isPending, sourceFs, jsonError])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10">
|
||||
{/* Main Content */}
|
||||
<OperationWindowContent>
|
||||
{/* Path Display */}
|
||||
<PathField
|
||||
path={sourceFs || ''}
|
||||
setPath={setSourceFs}
|
||||
label="Path"
|
||||
placeholder="Enter a remote:/path to delete"
|
||||
showPicker={true}
|
||||
allowedKeys={['REMOTES', 'FAVORITES']}
|
||||
showFiles={true}
|
||||
/>
|
||||
|
||||
{supportsPurge && (
|
||||
<Alert
|
||||
color="primary"
|
||||
title="LET ME SHARE A TIP"
|
||||
variant="faded"
|
||||
className="min-h-none h-fit max-h-fit"
|
||||
>
|
||||
If you're deleting a entire folder, "{sourceRemoteName}" supports Purge
|
||||
which is more efficient!
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Accordion
|
||||
keepContentMounted={true}
|
||||
dividerProps={{
|
||||
className: 'opacity-50',
|
||||
}}
|
||||
>
|
||||
<AccordionItem
|
||||
key="filters"
|
||||
startContent={
|
||||
<Avatar color="danger" radius="lg" fallback={<FilterIcon />} />
|
||||
}
|
||||
indicator={<FilterIcon />}
|
||||
title="Filters"
|
||||
subtitle={getOptionsSubtitle(Object.keys(filterOptions).length)}
|
||||
>
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.filter ?? {}}
|
||||
optionsJson={filterOptionsJsonString}
|
||||
setOptionsJson={setFilterOptionsJsonString}
|
||||
availableOptions={filterFlags || []}
|
||||
isLocked={filterOptionsLocked}
|
||||
setIsLocked={setFilterOptionsLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
key="config"
|
||||
startContent={
|
||||
<Avatar color="default" radius="lg" fallback={<WrenchIcon />} />
|
||||
}
|
||||
indicator={<WrenchIcon />}
|
||||
title="Config"
|
||||
subtitle={getOptionsSubtitle(Object.keys(configOptions).length)}
|
||||
>
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.main ?? {}}
|
||||
optionsJson={configOptionsJsonString}
|
||||
setOptionsJson={setConfigOptionsJsonString}
|
||||
availableOptions={configFlags || []}
|
||||
isLocked={configOptionsLocked}
|
||||
setIsLocked={setConfigOptionsLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
key="cron"
|
||||
startContent={
|
||||
<Avatar color="warning" radius="lg" fallback={<ClockIcon />} />
|
||||
}
|
||||
indicator={<ClockIcon />}
|
||||
title="Cron"
|
||||
>
|
||||
<CronEditor expression={cronExpression} onChange={setCronExpression} />
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</OperationWindowContent>
|
||||
|
||||
<OperationWindowFooter>
|
||||
<TemplatesDropdown
|
||||
isDisabled={!!jsonError}
|
||||
operation="delete"
|
||||
onSelect={(groupedOptions, shouldMerge) => {
|
||||
startTransition(() => {
|
||||
if (shouldMerge) {
|
||||
if (groupedOptions.filter)
|
||||
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2))
|
||||
if (groupedOptions.config)
|
||||
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2))
|
||||
} else {
|
||||
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2))
|
||||
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2))
|
||||
}
|
||||
})
|
||||
}}
|
||||
getOptions={() => ({
|
||||
...filterOptions,
|
||||
...configOptions,
|
||||
})}
|
||||
/>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{startDeleteMutation.isSuccess ? (
|
||||
<motion.div
|
||||
key="started-buttons"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="flex flex-1 gap-2"
|
||||
>
|
||||
<Dropdown shadow={platform() === 'windows' ? 'none' : undefined}>
|
||||
<DropdownTrigger>
|
||||
<Button fullWidth={true} size="lg" data-focus-visible="false">
|
||||
NEW DELETE
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu>
|
||||
<DropdownItem
|
||||
key="reset-paths"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setSourceFs(undefined)
|
||||
setJsonError(null)
|
||||
startDeleteMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset Path
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="reset-options"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setFilterOptionsJsonString('{}')
|
||||
setConfigOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.config,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setCronExpression(null)
|
||||
setJsonError(null)
|
||||
startDeleteMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset Options
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="reset-all"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setFilterOptionsJsonString('{}')
|
||||
setConfigOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.config,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setFilterOptionsLocked(false)
|
||||
setConfigOptionsLocked(false)
|
||||
setCronExpression(null)
|
||||
setJsonError(null)
|
||||
setSourceFs(undefined)
|
||||
startDeleteMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset All
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="start-button"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="flex flex-1"
|
||||
>
|
||||
<Button
|
||||
onPress={() => setTimeout(() => startDeleteMutation.mutate(), 100)}
|
||||
size="lg"
|
||||
fullWidth={true}
|
||||
type="button"
|
||||
color="primary"
|
||||
isDisabled={
|
||||
startDeleteMutation.isPending ||
|
||||
!!jsonError ||
|
||||
!sourceFs ||
|
||||
sourceFs.length === 0
|
||||
}
|
||||
isLoading={startDeleteMutation.isPending}
|
||||
endContent={buttonIcon}
|
||||
className="max-w-2xl gap-2"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<ButtonGroup variant="flat">
|
||||
<Tooltip
|
||||
content="Preview (Dry Run)"
|
||||
placement="top"
|
||||
size="lg"
|
||||
color="foreground"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
type="button"
|
||||
color="primary"
|
||||
isIconOnly={true}
|
||||
isLoading={dryRunMutation.isPending}
|
||||
onPress={() => {
|
||||
if (
|
||||
dryRunMutation.isPending ||
|
||||
!!jsonError ||
|
||||
!sourceFs ||
|
||||
sourceFs.length === 0
|
||||
) {
|
||||
return
|
||||
}
|
||||
setTimeout(() => dryRunMutation.mutate(), 100)
|
||||
}}
|
||||
>
|
||||
<EyeIcon className="size-6" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Schedule task" placement="top" size="lg" color="foreground">
|
||||
<Button
|
||||
size="lg"
|
||||
type="button"
|
||||
color="primary"
|
||||
isIconOnly={true}
|
||||
onPress={() => {
|
||||
setTimeout(() => scheduleTaskMutation.mutate(), 100)
|
||||
}}
|
||||
>
|
||||
<ClockIcon className="size-6" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<CommandInfoButton
|
||||
content={`Removes files from the specified path.
|
||||
const HELP_CONTENT = `Removes files from the specified path.
|
||||
|
||||
Unlike "Purge", Delete obeys include/exclude filters, so you can use it to selectively delete specific files. Delete only removes files but leaves the directory structure intact — empty folders will remain after the files are deleted.
|
||||
|
||||
@@ -529,10 +49,248 @@ Expand the accordion sections to customize your delete operation. Tap any chip o
|
||||
Tap the folder icon in the bottom bar to load or save option presets. Templates let you quickly apply common filter configurations for recurring cleanup tasks.
|
||||
|
||||
4. START THE DELETE
|
||||
Once a path is selected, tap "START DELETE" to begin. The operation will delete all files matching your filters (or all files if no filters are set). Empty directories will be left behind unless you use the rmdirs option.`}
|
||||
Once a path is selected, tap "START DELETE" to begin. The operation will delete all files matching your filters (or all files if no filters are set). Empty directories will be left behind unless you use the rmdirs option.`
|
||||
|
||||
export default function Delete() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { globalFlags, filterFlags, configFlags } = useFlags()
|
||||
|
||||
const [sourceFs, setSourceFs] = useState<string | undefined>(
|
||||
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
|
||||
)
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
jsonError,
|
||||
setJsonError,
|
||||
groups: optionGroups,
|
||||
applyTemplate,
|
||||
getMergedOptions,
|
||||
resetJson,
|
||||
resetLocks,
|
||||
} = useOptionGroups({
|
||||
groups: [{ key: 'filter' }, { key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config }],
|
||||
})
|
||||
const filterGroup = optionGroups.filter
|
||||
const configGroup = optionGroups.config
|
||||
|
||||
const sourceRemoteName = useMemo(() => getRemoteName(sourceFs), [sourceFs])
|
||||
|
||||
const sourceRemoteConfigQuery = useRemoteConfig(sourceRemoteName)
|
||||
|
||||
const supportsPurge = useMemo(
|
||||
() =>
|
||||
sourceRemoteConfigQuery.data
|
||||
? SUPPORTS_PURGE.includes(sourceRemoteConfigQuery.data.type)
|
||||
: false,
|
||||
[sourceRemoteConfigQuery.data]
|
||||
)
|
||||
|
||||
const buildArgs = () => ({
|
||||
sources: [sourceFs!],
|
||||
options: {
|
||||
filter: filterGroup.options,
|
||||
config: configGroup.options,
|
||||
},
|
||||
})
|
||||
|
||||
const startDeleteMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!sourceFs) {
|
||||
throw new Error('Please select a source path to delete')
|
||||
}
|
||||
|
||||
return startDelete(buildArgs())
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await notify({
|
||||
title: 'Success',
|
||||
body: 'Delete task started',
|
||||
})
|
||||
if (cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
onError: onErrorDialog('Delete', 'Failed to start delete', {
|
||||
log: ['Error starting delete:'],
|
||||
}),
|
||||
})
|
||||
|
||||
const scheduleTaskMutation = useScheduleTask({
|
||||
operation: 'delete',
|
||||
cronExpression,
|
||||
validate: () => {
|
||||
if (!sourceFs) {
|
||||
throw new Error('Please select a source path to delete')
|
||||
}
|
||||
},
|
||||
buildArgs,
|
||||
})
|
||||
|
||||
const dryRunMutation = useOperationDryRun(async () => {
|
||||
if (!sourceFs) {
|
||||
throw new Error('Please select a source path to delete')
|
||||
}
|
||||
return startDryRun(() =>
|
||||
startDelete({
|
||||
sources: [sourceFs],
|
||||
options: {
|
||||
filter: filterGroup.options,
|
||||
config: { ...configGroup.options, dry_run: true },
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
if (startDeleteMutation.isPending) return 'STARTING...'
|
||||
if (!sourceFs || sourceFs.length === 0) return 'Please select a source path'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
if (cronExpression) return 'START AND SCHEDULE DELETE'
|
||||
return 'START DELETE'
|
||||
}, [startDeleteMutation.isPending, sourceFs, jsonError, cronExpression])
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (startDeleteMutation.isPending) return
|
||||
if (!sourceFs || sourceFs.length === 0) return <FoldersIcon className="w-5 h-5" />
|
||||
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
|
||||
return <PlayIcon className="w-5 h-5 fill-current" />
|
||||
}, [startDeleteMutation.isPending, sourceFs, jsonError])
|
||||
|
||||
const accordionItems = useMemo<OptionsAccordionItemDef[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'filters',
|
||||
category: 'filters',
|
||||
subtitle: getOptionsSubtitle(Object.keys(filterGroup.options).length),
|
||||
children: (
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.filter ?? {}}
|
||||
optionsJson={filterGroup.jsonString}
|
||||
setOptionsJson={filterGroup.setJsonString}
|
||||
availableOptions={filterFlags || []}
|
||||
isLocked={filterGroup.locked}
|
||||
setIsLocked={filterGroup.setLocked}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
category: 'config',
|
||||
subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length),
|
||||
children: (
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.main ?? {}}
|
||||
optionsJson={configGroup.jsonString}
|
||||
setOptionsJson={configGroup.setJsonString}
|
||||
availableOptions={configFlags || []}
|
||||
isLocked={configGroup.locked}
|
||||
setIsLocked={configGroup.setLocked}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cron',
|
||||
category: 'cron',
|
||||
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
|
||||
},
|
||||
],
|
||||
[filterGroup, configGroup, globalFlags, filterFlags, configFlags, cronExpression]
|
||||
)
|
||||
|
||||
const handleStart = useCallback(
|
||||
() => startDeleteMutation.mutate(),
|
||||
[startDeleteMutation.mutate]
|
||||
)
|
||||
|
||||
const handleSchedule = useCallback(
|
||||
() => scheduleTaskMutation.mutate(),
|
||||
[scheduleTaskMutation.mutate]
|
||||
)
|
||||
|
||||
const handleDryRun = useCallback(() => dryRunMutation.mutate(), [dryRunMutation.mutate])
|
||||
|
||||
const handleResetPaths = useCallback(() => {
|
||||
startTransition(() => {
|
||||
setSourceFs(undefined)
|
||||
setJsonError(null)
|
||||
startDeleteMutation.reset()
|
||||
})
|
||||
}, [setJsonError, startDeleteMutation.reset])
|
||||
|
||||
const handleResetOptions = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
setCronExpression(null)
|
||||
startDeleteMutation.reset()
|
||||
})
|
||||
}, [resetJson, startDeleteMutation.reset])
|
||||
|
||||
const handleResetAll = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
resetLocks()
|
||||
setCronExpression(null)
|
||||
setSourceFs(undefined)
|
||||
startDeleteMutation.reset()
|
||||
})
|
||||
}, [resetJson, resetLocks, startDeleteMutation.reset])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10">
|
||||
{/* Main Content */}
|
||||
<OperationWindowContent>
|
||||
{/* Path Display */}
|
||||
<PathField
|
||||
path={sourceFs || ''}
|
||||
setPath={setSourceFs}
|
||||
label="Path"
|
||||
placeholder="Enter a remote:/path to delete"
|
||||
showPicker={true}
|
||||
allowedKeys={PATH_ALLOWED_KEYS}
|
||||
showFiles={true}
|
||||
/>
|
||||
|
||||
{supportsPurge && (
|
||||
<Alert
|
||||
color="primary"
|
||||
title="LET ME SHARE A TIP"
|
||||
variant="faded"
|
||||
className="min-h-none h-fit max-h-fit"
|
||||
>
|
||||
If you're deleting a entire folder, "{sourceRemoteName}" supports Purge
|
||||
which is more efficient!
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<OptionsAccordion items={accordionItems} />
|
||||
</OperationWindowContent>
|
||||
|
||||
<OperationWindowFooter>
|
||||
<OperationFooter
|
||||
operation="delete"
|
||||
templatesDisabled={!!jsonError}
|
||||
onTemplateSelect={applyTemplate}
|
||||
getTemplateOptions={getMergedOptions}
|
||||
startIsSuccess={startDeleteMutation.isSuccess}
|
||||
startIsPending={startDeleteMutation.isPending}
|
||||
onStart={handleStart}
|
||||
onSchedule={handleSchedule}
|
||||
dryRunIsPending={dryRunMutation.isPending}
|
||||
onDryRun={handleDryRun}
|
||||
startBlocked={!!jsonError || !sourceFs || sourceFs.length === 0}
|
||||
buttonText={buttonText}
|
||||
buttonIcon={buttonIcon}
|
||||
newLabel="NEW DELETE"
|
||||
newButtonPrimary={false}
|
||||
showViewTransfers={false}
|
||||
resetPathsLabel="Reset Path"
|
||||
onResetPaths={handleResetPaths}
|
||||
onResetOptions={handleResetOptions}
|
||||
onResetAll={handleResetAll}
|
||||
helpContent={HELP_CONTENT}
|
||||
/>
|
||||
<CommandsDropdown currentCommand="delete" />
|
||||
</ButtonGroup>
|
||||
</OperationWindowFooter>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AlertOctagonIcon, ClockIcon, DownloadIcon, FoldersIcon } from 'lucide-r
|
||||
import pRetry from 'p-retry'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { onErrorDialog } from '../../lib/errors'
|
||||
import notify from '../../lib/notify'
|
||||
import rclone from '../../lib/rclone/client'
|
||||
import CommandInfoButton from '../components/CommandInfoButton'
|
||||
@@ -107,14 +108,11 @@ export default function Download() {
|
||||
body: 'Download task started',
|
||||
})
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('[Download] Failed to start download', error)
|
||||
await message(error instanceof Error ? error.message : 'Failed to start download', {
|
||||
title: 'Download Error',
|
||||
kind: 'error',
|
||||
onError: onErrorDialog('Download Error', 'Failed to start download', {
|
||||
okLabel: 'OK',
|
||||
})
|
||||
},
|
||||
capture: false,
|
||||
log: ['[Download] Failed to start download'],
|
||||
}),
|
||||
})
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
|
||||
+49
-14
@@ -28,6 +28,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { reportError } from '../../lib/errors'
|
||||
import { getOptionsSubtitle } from '../../lib/flags'
|
||||
import { useFlags } from '../../lib/hooks'
|
||||
import { startMount } from '../../lib/rclone/api'
|
||||
@@ -164,13 +165,11 @@ export default function Mount() {
|
||||
}
|
||||
console.log('[Mount] Mount plugin installed, but failed to start mount')
|
||||
console.error('Failed to start mount:', error)
|
||||
await message(
|
||||
error instanceof Error ? error.message : 'Failed to start mount operation',
|
||||
{
|
||||
await reportError(error, {
|
||||
title: 'Mount Error',
|
||||
kind: 'error',
|
||||
}
|
||||
)
|
||||
fallback: 'Failed to start mount operation',
|
||||
capture: false,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -308,18 +307,54 @@ export default function Mount() {
|
||||
startTransition(() => {
|
||||
if (shouldMerge) {
|
||||
if (groupedOptions.mount)
|
||||
setMountOptionsJsonString(JSON.stringify({ ...mountOptions, ...groupedOptions.mount }, null, 2))
|
||||
setMountOptionsJsonString(
|
||||
JSON.stringify(
|
||||
{ ...mountOptions, ...groupedOptions.mount },
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
if (groupedOptions.vfs)
|
||||
setVfsOptionsJsonString(JSON.stringify({ ...vfsOptions, ...groupedOptions.vfs }, null, 2))
|
||||
setVfsOptionsJsonString(
|
||||
JSON.stringify(
|
||||
{ ...vfsOptions, ...groupedOptions.vfs },
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
if (groupedOptions.filter)
|
||||
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2))
|
||||
setFilterOptionsJsonString(
|
||||
JSON.stringify(
|
||||
{ ...filterOptions, ...groupedOptions.filter },
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
if (groupedOptions.config)
|
||||
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2))
|
||||
setConfigOptionsJsonString(
|
||||
JSON.stringify(
|
||||
{ ...configOptions, ...groupedOptions.config },
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
} else {
|
||||
if (groupedOptions.mount) setMountOptionsJsonString(JSON.stringify(groupedOptions.mount, null, 2))
|
||||
if (groupedOptions.vfs) setVfsOptionsJsonString(JSON.stringify(groupedOptions.vfs, null, 2))
|
||||
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2))
|
||||
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2))
|
||||
if (groupedOptions.mount)
|
||||
setMountOptionsJsonString(
|
||||
JSON.stringify(groupedOptions.mount, null, 2)
|
||||
)
|
||||
if (groupedOptions.vfs)
|
||||
setVfsOptionsJsonString(
|
||||
JSON.stringify(groupedOptions.vfs, null, 2)
|
||||
)
|
||||
if (groupedOptions.filter)
|
||||
setFilterOptionsJsonString(
|
||||
JSON.stringify(groupedOptions.filter, null, 2)
|
||||
)
|
||||
if (groupedOptions.config)
|
||||
setConfigOptionsJsonString(
|
||||
JSON.stringify(groupedOptions.config, null, 2)
|
||||
)
|
||||
}
|
||||
})
|
||||
}}
|
||||
|
||||
+302
-636
@@ -1,650 +1,28 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger,
|
||||
Tooltip,
|
||||
} from '@heroui/react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { ask, message } from '@tauri-apps/plugin-dialog'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import cronstrue from 'cronstrue'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
AlertOctagonIcon,
|
||||
ClockIcon,
|
||||
EyeIcon,
|
||||
FilterIcon,
|
||||
FoldersIcon,
|
||||
MoveIcon,
|
||||
PlayIcon,
|
||||
ServerIcon,
|
||||
WrenchIcon,
|
||||
} from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
|
||||
import { startTransition, useCallback, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { onErrorDialog } from '../../lib/errors'
|
||||
import { getOptionsSubtitle } from '../../lib/flags'
|
||||
import { useFlags } from '../../lib/hooks'
|
||||
import notify from '../../lib/notify'
|
||||
import { startDryRun, startMove } from '../../lib/rclone/api'
|
||||
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
|
||||
import { openWindow } from '../../lib/window'
|
||||
import { useHostStore } from '../../store/host'
|
||||
import { usePersistedStore } from '../../store/persisted'
|
||||
import type { FlagValue } from '../../types/rclone'
|
||||
import CommandInfoButton from '../components/CommandInfoButton'
|
||||
import CommandsDropdown from '../components/CommandsDropdown'
|
||||
import CronEditor from '../components/CronEditor'
|
||||
import OperationWindowContent from '../components/OperationWindowContent'
|
||||
import OperationWindowFooter from '../components/OperationWindowFooter'
|
||||
import OptionsSection from '../components/OptionsSection'
|
||||
import { MultiPathFinder } from '../components/PathFinder'
|
||||
import RemoteOptionsSection from '../components/RemoteOptionsSection'
|
||||
import ShowMoreOptionsBanner from '../components/ShowMoreOptionsBanner'
|
||||
import TemplatesDropdown from '../components/TemplatesDropdown'
|
||||
import OperationFooter from '../components/operation/OperationFooter'
|
||||
import OptionsAccordion, {
|
||||
type OptionsAccordionItemDef,
|
||||
} from '../components/operation/OptionsAccordion'
|
||||
import { useOperationDryRun } from '../components/operation/useOperationDryRun'
|
||||
import { useOptionGroups } from '../components/operation/useOptionGroups'
|
||||
import { useScheduleTask } from '../components/operation/useScheduleTask'
|
||||
|
||||
/*
|
||||
if (cronExpression) {
|
||||
if (sources.length > 1) {
|
||||
throw new Error(
|
||||
'Cron is not supported for multiple sources, please use a single source'
|
||||
)
|
||||
}
|
||||
try {
|
||||
cronstrue.toString(cronExpression)
|
||||
} catch {
|
||||
throw new Error('Invalid cron expression')
|
||||
}
|
||||
useHostStore.getState().addScheduledTask({
|
||||
type: 'move',
|
||||
cron: cronExpression,
|
||||
args: {
|
||||
srcFs: sources[0],
|
||||
dstFs: dest,
|
||||
createEmptySrcDirs,
|
||||
deleteEmptyDstDirs,
|
||||
_config: mergedConfig,
|
||||
_filter: filterOptions,
|
||||
},
|
||||
})
|
||||
}
|
||||
*/
|
||||
|
||||
export default function Move() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { globalFlags, filterFlags, configFlags, copyFlags } = useFlags()
|
||||
|
||||
const [sources, setSources] = useState<string[] | undefined>(
|
||||
searchParams.get('initialSource') ? [searchParams.get('initialSource')!] : undefined
|
||||
)
|
||||
const [dest, setDest] = useState<string | undefined>(
|
||||
searchParams.get('initialDest') ? searchParams.get('initialDest')! : undefined
|
||||
)
|
||||
|
||||
const [jsonError, setJsonError] = useState<'move' | 'filter' | 'config' | 'remote' | null>(null)
|
||||
|
||||
const [moveOptionsLocked, setMoveOptionsLocked] = useState(false)
|
||||
const [moveOptions, setMoveOptions] = useState<Record<string, FlagValue>>({})
|
||||
const [moveOptionsJsonString, setMoveOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const [filterOptionsLocked, setFilterOptionsLocked] = useState(false)
|
||||
const [filterOptions, setFilterOptions] = useState<Record<string, FlagValue>>({})
|
||||
const [filterOptionsJsonString, setFilterOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const [configOptionsLocked, setConfigOptionsLocked] = useState(false)
|
||||
const [configOptions, setConfigOptions] = useState<Record<string, FlagValue>>({})
|
||||
const [configOptionsJsonString, setConfigOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const [remoteOptionsLocked, setRemoteOptionsLocked] = useState(false)
|
||||
const [remoteOptions, setRemoteOptions] = useState<Record<string, Record<string, FlagValue>>>(
|
||||
{}
|
||||
)
|
||||
const [remoteOptionsJsonString, setRemoteOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
|
||||
const selectedRemotes = useMemo(
|
||||
() => [...(sources || []), dest].filter(Boolean),
|
||||
[sources, dest]
|
||||
)
|
||||
|
||||
const startMoveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!sources || sources.length === 0 || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
|
||||
return startMove({
|
||||
sources,
|
||||
destination: dest,
|
||||
options: {
|
||||
config: configOptions,
|
||||
move: moveOptions,
|
||||
filter: filterOptions,
|
||||
remotes: remoteOptions,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Error starting move:', error)
|
||||
await message(error instanceof Error ? error.message : 'Failed to start move', {
|
||||
title: 'Move',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const scheduleTaskMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!sources || sources.length === 0 || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
|
||||
if (sources.length > 1 && !usePersistedStore.getState().licenseValid) {
|
||||
throw new Error('You need a valid license to schedule multiple tasks at once')
|
||||
}
|
||||
|
||||
if (!cronExpression) {
|
||||
throw new Error('Please enter a cron expression')
|
||||
}
|
||||
|
||||
try {
|
||||
cronstrue.toString(cronExpression)
|
||||
} catch {
|
||||
throw new Error('Invalid cron expression')
|
||||
}
|
||||
|
||||
const name = await invoke<string | null>('prompt', {
|
||||
title: 'Schedule Name',
|
||||
message: 'Enter a name for this schedule',
|
||||
default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`,
|
||||
})
|
||||
|
||||
if (!name) {
|
||||
throw new Error('Schedule name is required')
|
||||
}
|
||||
|
||||
useHostStore.getState().addScheduledTask({
|
||||
name,
|
||||
operation: 'move',
|
||||
cron: cronExpression,
|
||||
args: {
|
||||
sources,
|
||||
destination: dest,
|
||||
options: {
|
||||
config: configOptions,
|
||||
move: moveOptions,
|
||||
filter: filterOptions,
|
||||
remotes: remoteOptions,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await notify({
|
||||
title: 'Success',
|
||||
body: 'New schedule has been created',
|
||||
})
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Error scheduling task:', error)
|
||||
await message(error instanceof Error ? error.message : 'Failed to schedule task', {
|
||||
title: 'Schedule',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const dryRunMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!sources || sources.length === 0 || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
return startDryRun(() =>
|
||||
startMove({
|
||||
sources,
|
||||
destination: dest,
|
||||
options: {
|
||||
config: { ...configOptions, dry_run: true },
|
||||
move: moveOptions,
|
||||
filter: filterOptions,
|
||||
remotes: remoteOptions,
|
||||
},
|
||||
})
|
||||
)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
const result = await ask(
|
||||
'Dry run started, you can check the results in the Transfers screen',
|
||||
{
|
||||
title: 'Preview (Dry Run)',
|
||||
kind: 'info',
|
||||
okLabel: 'Open Transfers',
|
||||
cancelLabel: 'OK',
|
||||
}
|
||||
)
|
||||
if (result) {
|
||||
await openWindow({ name: 'Transfers', url: '/transfers' })
|
||||
}
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Error starting dry run:', error)
|
||||
await message(error instanceof Error ? error.message : 'Failed to start dry run', {
|
||||
title: 'Dry Run',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2))
|
||||
setMoveOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.copy, null, 2))
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let step: 'move' | 'filter' | 'config' | 'remote' = 'move'
|
||||
try {
|
||||
const parsedMove = JSON.parse(moveOptionsJsonString) as Record<string, FlagValue>
|
||||
|
||||
step = 'filter'
|
||||
const parsedFilter = JSON.parse(filterOptionsJsonString) as Record<string, FlagValue>
|
||||
|
||||
step = 'config'
|
||||
const parsedConfig = JSON.parse(configOptionsJsonString) as Record<string, FlagValue>
|
||||
|
||||
step = 'remote'
|
||||
const outerRemote = JSON.parse(remoteOptionsJsonString) as Record<string, string>
|
||||
const parsedRemote: Record<string, Record<string, FlagValue>> = {}
|
||||
for (const [key, val] of Object.entries(outerRemote)) {
|
||||
parsedRemote[key] = JSON.parse(val) as Record<string, FlagValue>
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setMoveOptions(parsedMove)
|
||||
setFilterOptions(parsedFilter)
|
||||
setConfigOptions(parsedConfig)
|
||||
setRemoteOptions(parsedRemote)
|
||||
setJsonError(null)
|
||||
})
|
||||
} catch (error) {
|
||||
setJsonError(step)
|
||||
console.error(`Error parsing ${step} options:`, error)
|
||||
}
|
||||
}, [
|
||||
moveOptionsJsonString,
|
||||
filterOptionsJsonString,
|
||||
configOptionsJsonString,
|
||||
remoteOptionsJsonString,
|
||||
])
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
if (startMoveMutation.isPending) return 'STARTING...'
|
||||
if (!sources || sources.length === 0) return 'Please select a source path'
|
||||
if (!dest) return 'Please select a destination path'
|
||||
if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
if (cronExpression) return 'START AND SCHEDULE MOVE'
|
||||
return 'START MOVE'
|
||||
}, [startMoveMutation.isPending, sources, dest, jsonError, cronExpression])
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (startMoveMutation.isPending) return
|
||||
if (!sources || sources.length === 0 || !dest || sources.some((s) => s === dest))
|
||||
return <FoldersIcon className="w-5 h-5" />
|
||||
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
|
||||
return <PlayIcon className="w-5 h-5 fill-current" />
|
||||
}, [startMoveMutation.isPending, sources, dest, jsonError])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10">
|
||||
{/* Main Content */}
|
||||
<OperationWindowContent>
|
||||
{/* Paths Display */}
|
||||
<MultiPathFinder
|
||||
sourcePaths={sources}
|
||||
setSourcePaths={setSources}
|
||||
destPath={dest}
|
||||
setDestPath={setDest}
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-col">
|
||||
<Accordion
|
||||
keepContentMounted={true}
|
||||
dividerProps={{
|
||||
className: 'opacity-50',
|
||||
}}
|
||||
>
|
||||
<AccordionItem
|
||||
key="move"
|
||||
startContent={
|
||||
<Avatar color="primary" radius="lg" fallback={<MoveIcon />} />
|
||||
}
|
||||
indicator={<MoveIcon />}
|
||||
title="Move"
|
||||
subtitle={getOptionsSubtitle(Object.keys(moveOptions).length)}
|
||||
>
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.main || {}}
|
||||
optionsJson={moveOptionsJsonString}
|
||||
setOptionsJson={setMoveOptionsJsonString}
|
||||
availableOptions={copyFlags || []}
|
||||
isLocked={moveOptionsLocked}
|
||||
setIsLocked={setMoveOptionsLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
key="filters"
|
||||
startContent={
|
||||
<Avatar color="danger" radius="lg" fallback={<FilterIcon />} />
|
||||
}
|
||||
indicator={<FilterIcon />}
|
||||
title="Filters"
|
||||
subtitle={getOptionsSubtitle(Object.keys(filterOptions).length)}
|
||||
>
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.filter || {}}
|
||||
optionsJson={filterOptionsJsonString}
|
||||
setOptionsJson={setFilterOptionsJsonString}
|
||||
availableOptions={filterFlags || []}
|
||||
isLocked={filterOptionsLocked}
|
||||
setIsLocked={setFilterOptionsLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
key="cron"
|
||||
startContent={
|
||||
<Avatar color="warning" radius="lg" fallback={<ClockIcon />} />
|
||||
}
|
||||
indicator={<ClockIcon />}
|
||||
title="Cron"
|
||||
>
|
||||
<CronEditor expression={cronExpression} onChange={setCronExpression} />
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
key="config"
|
||||
startContent={
|
||||
<Avatar color="default" radius="lg" fallback={<WrenchIcon />} />
|
||||
}
|
||||
indicator={<WrenchIcon />}
|
||||
title="Config"
|
||||
subtitle={getOptionsSubtitle(Object.keys(configOptions).length)}
|
||||
>
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.main || {}}
|
||||
optionsJson={configOptionsJsonString}
|
||||
setOptionsJson={setConfigOptionsJsonString}
|
||||
availableOptions={configFlags || []}
|
||||
isLocked={configOptionsLocked}
|
||||
setIsLocked={setConfigOptionsLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
|
||||
{selectedRemotes.length > 0 ? (
|
||||
<AccordionItem
|
||||
key={'remotes'}
|
||||
startContent={
|
||||
<Avatar
|
||||
className="bg-fuchsia-500"
|
||||
radius="lg"
|
||||
fallback={<ServerIcon />}
|
||||
/>
|
||||
}
|
||||
indicator={<ServerIcon />}
|
||||
title={'Remotes'}
|
||||
subtitle={getOptionsSubtitle(
|
||||
Object.values(remoteOptions).reduce(
|
||||
(acc, opts) => acc + Object.keys(opts).length,
|
||||
0
|
||||
)
|
||||
)}
|
||||
>
|
||||
<RemoteOptionsSection
|
||||
selectedRemotes={selectedRemotes}
|
||||
remoteOptionsJsonString={remoteOptionsJsonString}
|
||||
setRemoteOptionsJsonString={setRemoteOptionsJsonString}
|
||||
setRemoteOptionsLocked={setRemoteOptionsLocked}
|
||||
remoteOptionsLocked={remoteOptionsLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
) : null}
|
||||
</Accordion>
|
||||
|
||||
<ShowMoreOptionsBanner />
|
||||
</div>
|
||||
</OperationWindowContent>
|
||||
|
||||
<OperationWindowFooter>
|
||||
<TemplatesDropdown
|
||||
isDisabled={!!jsonError}
|
||||
operation="move"
|
||||
onSelect={(groupedOptions, shouldMerge) => {
|
||||
startTransition(() => {
|
||||
if (shouldMerge) {
|
||||
if (groupedOptions.copy)
|
||||
setMoveOptionsJsonString(JSON.stringify({ ...moveOptions, ...groupedOptions.copy }, null, 2))
|
||||
if (groupedOptions.filter)
|
||||
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2))
|
||||
if (groupedOptions.config)
|
||||
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2))
|
||||
} else {
|
||||
if (groupedOptions.copy) setMoveOptionsJsonString(JSON.stringify(groupedOptions.copy, null, 2))
|
||||
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2))
|
||||
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2))
|
||||
}
|
||||
})
|
||||
}}
|
||||
getOptions={() => ({
|
||||
...moveOptions,
|
||||
...filterOptions,
|
||||
...configOptions,
|
||||
})}
|
||||
/>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{startMoveMutation.isSuccess ? (
|
||||
<motion.div
|
||||
key="started-buttons"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="flex flex-1 gap-2"
|
||||
>
|
||||
<Dropdown shadow={platform() === 'windows' ? 'none' : undefined}>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
fullWidth={true}
|
||||
color="primary"
|
||||
size="lg"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
NEW MOVE
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu>
|
||||
<DropdownItem
|
||||
key="reset-paths"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setSources(undefined)
|
||||
setDest(undefined)
|
||||
setJsonError(null)
|
||||
startMoveMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset Paths
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="reset-options"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setMoveOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.copy,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setFilterOptionsJsonString('{}')
|
||||
setConfigOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.config,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setRemoteOptionsJsonString('{}')
|
||||
setCronExpression(null)
|
||||
setJsonError(null)
|
||||
startMoveMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset Options
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="reset-all"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setMoveOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.copy,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setFilterOptionsJsonString('{}')
|
||||
setConfigOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.config,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setRemoteOptionsJsonString('{}')
|
||||
setMoveOptionsLocked(false)
|
||||
setFilterOptionsLocked(false)
|
||||
setConfigOptionsLocked(false)
|
||||
setRemoteOptionsLocked(false)
|
||||
setCronExpression(null)
|
||||
setJsonError(null)
|
||||
setSources(undefined)
|
||||
setDest(undefined)
|
||||
startMoveMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset All
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
|
||||
<Button
|
||||
fullWidth={true}
|
||||
size="lg"
|
||||
color="secondary"
|
||||
onPress={async () => {
|
||||
await openWindow({
|
||||
name: 'Transfers',
|
||||
url: '/transfers',
|
||||
})
|
||||
}}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
VIEW TRANSFERS
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="start-button"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="flex flex-1"
|
||||
>
|
||||
<Button
|
||||
onPress={() => setTimeout(() => startMoveMutation.mutate(), 100)}
|
||||
size="lg"
|
||||
fullWidth={true}
|
||||
type="button"
|
||||
color="primary"
|
||||
isDisabled={
|
||||
startMoveMutation.isPending ||
|
||||
!!jsonError ||
|
||||
!sources ||
|
||||
sources.length === 0 ||
|
||||
!dest ||
|
||||
sources.some((s) => s === dest)
|
||||
}
|
||||
isLoading={startMoveMutation.isPending}
|
||||
endContent={buttonIcon}
|
||||
className="max-w-2xl gap-2"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<ButtonGroup variant="flat">
|
||||
<Tooltip
|
||||
content="Preview (Dry Run)"
|
||||
placement="top"
|
||||
size="lg"
|
||||
color="foreground"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
type="button"
|
||||
color="primary"
|
||||
isIconOnly={true}
|
||||
isLoading={dryRunMutation.isPending}
|
||||
onPress={() => {
|
||||
if (
|
||||
dryRunMutation.isPending ||
|
||||
!!jsonError ||
|
||||
!sources ||
|
||||
sources.length === 0 ||
|
||||
!dest ||
|
||||
sources.some((s) => s === dest)
|
||||
) {
|
||||
return
|
||||
}
|
||||
setTimeout(() => dryRunMutation.mutate(), 100)
|
||||
}}
|
||||
>
|
||||
<EyeIcon className="size-6" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Schedule task" placement="top" size="lg" color="foreground">
|
||||
<Button
|
||||
size="lg"
|
||||
type="button"
|
||||
color="primary"
|
||||
isIconOnly={true}
|
||||
onPress={() => {
|
||||
setTimeout(() => scheduleTaskMutation.mutate(), 100)
|
||||
}}
|
||||
>
|
||||
<ClockIcon className="size-6" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<CommandInfoButton
|
||||
content={`Moves the source(s) to the destination directory.
|
||||
const HELP_CONTENT = `Moves the source(s) to the destination directory.
|
||||
|
||||
Unlike Copy, Move deletes files from the source after they have been transferred to the destination. After a successful move, the source path will no longer exist.
|
||||
|
||||
@@ -676,10 +54,298 @@ Expand the accordion sections to customize your move operation. Tap any chip on
|
||||
Tap the folder icon in the bottom bar to load or save option presets. Templates let you quickly apply common configurations without manually setting each option.
|
||||
|
||||
4. START THE MOVE
|
||||
Once paths are selected, tap "START MOVE" to begin. You can monitor progress on the Transfers page.`}
|
||||
Once paths are selected, tap "START MOVE" to begin. You can monitor progress on the Transfers page.`
|
||||
|
||||
export default function Move() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { globalFlags, filterFlags, configFlags, copyFlags } = useFlags()
|
||||
|
||||
const [sources, setSources] = useState<string[] | undefined>(
|
||||
searchParams.get('initialSource') ? [searchParams.get('initialSource')!] : undefined
|
||||
)
|
||||
const [dest, setDest] = useState<string | undefined>(
|
||||
searchParams.get('initialDest') ? searchParams.get('initialDest')! : undefined
|
||||
)
|
||||
|
||||
const {
|
||||
jsonError,
|
||||
setJsonError,
|
||||
groups: optionGroups,
|
||||
remotes: remotesGroup,
|
||||
applyTemplate,
|
||||
getMergedOptions,
|
||||
resetJson,
|
||||
resetLocks,
|
||||
} = useOptionGroups({
|
||||
groups: [
|
||||
{ key: 'move', templateKey: 'copy', defaults: RCLONE_CONFIG_DEFAULTS.copy },
|
||||
{ key: 'filter' },
|
||||
{ key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config },
|
||||
],
|
||||
withRemotes: true,
|
||||
})
|
||||
const moveGroup = optionGroups.move
|
||||
const filterGroup = optionGroups.filter
|
||||
const configGroup = optionGroups.config
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
|
||||
const selectedRemotes = useMemo(
|
||||
() => [...(sources || []), dest].filter(Boolean),
|
||||
[sources, dest]
|
||||
)
|
||||
|
||||
const buildArgs = () => ({
|
||||
sources: sources!,
|
||||
destination: dest!,
|
||||
options: {
|
||||
config: configGroup.options,
|
||||
move: moveGroup.options,
|
||||
filter: filterGroup.options,
|
||||
remotes: remotesGroup.options,
|
||||
},
|
||||
})
|
||||
|
||||
const startMoveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!sources || sources.length === 0 || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
|
||||
return startMove(buildArgs())
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
onError: onErrorDialog('Move', 'Failed to start move', {
|
||||
capture: false,
|
||||
log: ['Error starting move:'],
|
||||
}),
|
||||
})
|
||||
|
||||
const scheduleTaskMutation = useScheduleTask({
|
||||
operation: 'move',
|
||||
cronExpression,
|
||||
validate: () => {
|
||||
if (!sources || sources.length === 0 || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
|
||||
if (sources.length > 1 && !usePersistedStore.getState().licenseValid) {
|
||||
throw new Error('You need a valid license to schedule multiple tasks at once')
|
||||
}
|
||||
},
|
||||
buildArgs,
|
||||
})
|
||||
|
||||
const dryRunMutation = useOperationDryRun(async () => {
|
||||
if (!sources || sources.length === 0 || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
return startDryRun(() =>
|
||||
startMove({
|
||||
sources,
|
||||
destination: dest,
|
||||
options: {
|
||||
config: { ...configGroup.options, dry_run: true },
|
||||
move: moveGroup.options,
|
||||
filter: filterGroup.options,
|
||||
remotes: remotesGroup.options,
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
if (startMoveMutation.isPending) return 'STARTING...'
|
||||
if (!sources || sources.length === 0) return 'Please select a source path'
|
||||
if (!dest) return 'Please select a destination path'
|
||||
if (sources.some((s) => s === dest)) return 'Source and destination cannot be the same'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
if (cronExpression) return 'START AND SCHEDULE MOVE'
|
||||
return 'START MOVE'
|
||||
}, [startMoveMutation.isPending, sources, dest, jsonError, cronExpression])
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (startMoveMutation.isPending) return
|
||||
if (!sources || sources.length === 0 || !dest || sources.some((s) => s === dest))
|
||||
return <FoldersIcon className="w-5 h-5" />
|
||||
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
|
||||
return <PlayIcon className="w-5 h-5 fill-current" />
|
||||
}, [startMoveMutation.isPending, sources, dest, jsonError])
|
||||
|
||||
const accordionItems = useMemo<OptionsAccordionItemDef[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'move',
|
||||
category: 'move',
|
||||
subtitle: getOptionsSubtitle(Object.keys(moveGroup.options).length),
|
||||
children: (
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.main || {}}
|
||||
optionsJson={moveGroup.jsonString}
|
||||
setOptionsJson={moveGroup.setJsonString}
|
||||
availableOptions={copyFlags || []}
|
||||
isLocked={moveGroup.locked}
|
||||
setIsLocked={moveGroup.setLocked}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'filters',
|
||||
category: 'filters',
|
||||
subtitle: getOptionsSubtitle(Object.keys(filterGroup.options).length),
|
||||
children: (
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.filter || {}}
|
||||
optionsJson={filterGroup.jsonString}
|
||||
setOptionsJson={filterGroup.setJsonString}
|
||||
availableOptions={filterFlags || []}
|
||||
isLocked={filterGroup.locked}
|
||||
setIsLocked={filterGroup.setLocked}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cron',
|
||||
category: 'cron',
|
||||
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
category: 'config',
|
||||
subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length),
|
||||
children: (
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.main || {}}
|
||||
optionsJson={configGroup.jsonString}
|
||||
setOptionsJson={configGroup.setJsonString}
|
||||
availableOptions={configFlags || []}
|
||||
isLocked={configGroup.locked}
|
||||
setIsLocked={configGroup.setLocked}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(selectedRemotes.length > 0
|
||||
? [
|
||||
{
|
||||
key: 'remotes',
|
||||
category: 'remotes' as const,
|
||||
subtitle: getOptionsSubtitle(
|
||||
Object.values(remotesGroup.options).reduce(
|
||||
(acc, opts) => acc + Object.keys(opts).length,
|
||||
0
|
||||
)
|
||||
),
|
||||
children: (
|
||||
<RemoteOptionsSection
|
||||
selectedRemotes={selectedRemotes}
|
||||
remoteOptionsJson={remotesGroup.json}
|
||||
setRemoteOptionsJson={remotesGroup.setJson}
|
||||
reconcileRemotes={remotesGroup.reconcile}
|
||||
setRemoteOptionsLocked={remotesGroup.setLocked}
|
||||
remoteOptionsLocked={remotesGroup.locked}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[
|
||||
moveGroup,
|
||||
filterGroup,
|
||||
configGroup,
|
||||
remotesGroup,
|
||||
globalFlags,
|
||||
filterFlags,
|
||||
configFlags,
|
||||
copyFlags,
|
||||
cronExpression,
|
||||
selectedRemotes,
|
||||
]
|
||||
)
|
||||
|
||||
const handleStart = useCallback(() => startMoveMutation.mutate(), [startMoveMutation.mutate])
|
||||
|
||||
const handleSchedule = useCallback(
|
||||
() => scheduleTaskMutation.mutate(),
|
||||
[scheduleTaskMutation.mutate]
|
||||
)
|
||||
|
||||
const handleDryRun = useCallback(() => dryRunMutation.mutate(), [dryRunMutation.mutate])
|
||||
|
||||
const handleResetPaths = useCallback(() => {
|
||||
startTransition(() => {
|
||||
setSources(undefined)
|
||||
setDest(undefined)
|
||||
setJsonError(null)
|
||||
startMoveMutation.reset()
|
||||
})
|
||||
}, [setJsonError, startMoveMutation.reset])
|
||||
|
||||
const handleResetOptions = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
setCronExpression(null)
|
||||
startMoveMutation.reset()
|
||||
})
|
||||
}, [resetJson, startMoveMutation.reset])
|
||||
|
||||
const handleResetAll = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
resetLocks()
|
||||
setCronExpression(null)
|
||||
setSources(undefined)
|
||||
setDest(undefined)
|
||||
startMoveMutation.reset()
|
||||
})
|
||||
}, [resetJson, resetLocks, startMoveMutation.reset])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10">
|
||||
{/* Main Content */}
|
||||
<OperationWindowContent>
|
||||
{/* Paths Display */}
|
||||
<MultiPathFinder
|
||||
sourcePaths={sources}
|
||||
setSourcePaths={setSources}
|
||||
destPath={dest}
|
||||
setDestPath={setDest}
|
||||
/>
|
||||
|
||||
<OptionsAccordion banner={true} items={accordionItems} />
|
||||
</OperationWindowContent>
|
||||
|
||||
<OperationWindowFooter>
|
||||
<OperationFooter
|
||||
operation="move"
|
||||
templatesDisabled={!!jsonError}
|
||||
onTemplateSelect={applyTemplate}
|
||||
getTemplateOptions={getMergedOptions}
|
||||
startIsSuccess={startMoveMutation.isSuccess}
|
||||
startIsPending={startMoveMutation.isPending}
|
||||
onStart={handleStart}
|
||||
onSchedule={handleSchedule}
|
||||
dryRunIsPending={dryRunMutation.isPending}
|
||||
onDryRun={handleDryRun}
|
||||
startBlocked={
|
||||
!!jsonError ||
|
||||
!sources ||
|
||||
sources.length === 0 ||
|
||||
!dest ||
|
||||
sources.some((s) => s === dest)
|
||||
}
|
||||
buttonText={buttonText}
|
||||
buttonIcon={buttonIcon}
|
||||
newLabel="NEW MOVE"
|
||||
onResetPaths={handleResetPaths}
|
||||
onResetOptions={handleResetOptions}
|
||||
onResetAll={handleResetAll}
|
||||
helpContent={HELP_CONTENT}
|
||||
/>
|
||||
<CommandsDropdown currentCommand="move" />
|
||||
</ButtonGroup>
|
||||
</OperationWindowFooter>
|
||||
</div>
|
||||
)
|
||||
|
||||
+188
-349
@@ -1,363 +1,29 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger,
|
||||
Tooltip,
|
||||
} from '@heroui/react'
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import cronstrue from 'cronstrue'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { AlertOctagonIcon, ClockIcon, FoldersIcon, PlayIcon, WrenchIcon } from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
|
||||
import { startTransition, useCallback, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { onErrorDialog } from '../../lib/errors'
|
||||
import { getOptionsSubtitle } from '../../lib/flags'
|
||||
import { useFlags } from '../../lib/hooks'
|
||||
import notify from '../../lib/notify'
|
||||
import { startPurge } from '../../lib/rclone/api'
|
||||
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
|
||||
import { useHostStore } from '../../store/host'
|
||||
import type { FlagValue } from '../../types/rclone'
|
||||
import CommandInfoButton from '../components/CommandInfoButton'
|
||||
import CommandsDropdown from '../components/CommandsDropdown'
|
||||
import CronEditor from '../components/CronEditor'
|
||||
import OperationWindowContent from '../components/OperationWindowContent'
|
||||
import OperationWindowFooter from '../components/OperationWindowFooter'
|
||||
import OptionsSection from '../components/OptionsSection'
|
||||
import { PathField } from '../components/PathFinder'
|
||||
import TemplatesDropdown from '../components/TemplatesDropdown'
|
||||
import OperationFooter from '../components/operation/OperationFooter'
|
||||
import OptionsAccordion, {
|
||||
type OptionsAccordionItemDef,
|
||||
} from '../components/operation/OptionsAccordion'
|
||||
import { useOptionGroups } from '../components/operation/useOptionGroups'
|
||||
import { useScheduleTask } from '../components/operation/useScheduleTask'
|
||||
|
||||
export default function Purge() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { globalFlags, configFlags } = useFlags()
|
||||
const PATH_ALLOWED_KEYS: ('LOCAL_FS' | 'FAVORITES' | 'REMOTES')[] = ['REMOTES', 'FAVORITES']
|
||||
|
||||
const [source, setSource] = useState<string | undefined>(
|
||||
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
|
||||
)
|
||||
const DEFAULT_EXPANDED_KEYS = ['config', 'cron']
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
|
||||
const [jsonError, setJsonError] = useState<'config' | null>(null)
|
||||
|
||||
const [configOptionsLocked, setConfigOptionsLocked] = useState(false)
|
||||
const [configOptions, setConfigOptions] = useState<Record<string, FlagValue>>({})
|
||||
const [configOptionsJsonString, setConfigOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const startPurgeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!source) {
|
||||
throw new Error('Please select a source path')
|
||||
}
|
||||
|
||||
return startPurge({
|
||||
sources: [source],
|
||||
options: {
|
||||
config: configOptions,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('[Purge] Failed to start purge:', error)
|
||||
Sentry.captureException(error)
|
||||
await message(error instanceof Error ? error.message : 'Failed to start purge', {
|
||||
title: 'Purge',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const scheduleTaskMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!source) {
|
||||
throw new Error('Please select a source path to purge')
|
||||
}
|
||||
|
||||
if (!cronExpression) {
|
||||
throw new Error('Please enter a cron expression')
|
||||
}
|
||||
|
||||
try {
|
||||
cronstrue.toString(cronExpression)
|
||||
} catch {
|
||||
throw new Error('Invalid cron expression')
|
||||
}
|
||||
|
||||
const name = await invoke<string | null>('prompt', {
|
||||
title: 'Schedule Name',
|
||||
message: 'Enter a name for this schedule',
|
||||
default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`,
|
||||
})
|
||||
|
||||
if (!name) {
|
||||
throw new Error('Schedule name is required')
|
||||
}
|
||||
|
||||
useHostStore.getState().addScheduledTask({
|
||||
name,
|
||||
operation: 'purge',
|
||||
cron: cronExpression,
|
||||
args: {
|
||||
sources: [source],
|
||||
options: {
|
||||
config: configOptions,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await notify({
|
||||
title: 'Success',
|
||||
body: 'New schedule has been created',
|
||||
})
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Error scheduling task:', error)
|
||||
await message(error instanceof Error ? error.message : 'Failed to schedule task', {
|
||||
title: 'Schedule',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
if (startPurgeMutation.isPending) return 'STARTING...'
|
||||
if (!source) return 'Please select a source path'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
if (cronExpression) return 'START AND SCHEDULE PURGE'
|
||||
return 'START PURGE'
|
||||
}, [startPurgeMutation.isPending, source, jsonError, cronExpression])
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (startPurgeMutation.isPending) return
|
||||
if (!source) return <FoldersIcon className="w-5 h-5" />
|
||||
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
|
||||
return <PlayIcon className="w-5 h-5 fill-current" />
|
||||
}, [startPurgeMutation.isPending, source, jsonError])
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2))
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const step = 'config'
|
||||
try {
|
||||
const parsedConfig = JSON.parse(configOptionsJsonString) as Record<string, FlagValue>
|
||||
|
||||
startTransition(() => {
|
||||
setConfigOptions(parsedConfig)
|
||||
setJsonError(null)
|
||||
})
|
||||
} catch (error) {
|
||||
setJsonError(step)
|
||||
console.error(`Error parsing ${step} options:`, error)
|
||||
}
|
||||
}, [configOptionsJsonString])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10">
|
||||
{/* Main Content */}
|
||||
<OperationWindowContent>
|
||||
{/* Path Display */}
|
||||
<PathField
|
||||
path={source || ''}
|
||||
setPath={setSource}
|
||||
label="Path"
|
||||
placeholder="Enter a remote:/path to purge"
|
||||
showPicker={true}
|
||||
allowedKeys={['REMOTES', 'FAVORITES']}
|
||||
showFiles={false}
|
||||
/>
|
||||
|
||||
<Accordion
|
||||
keepContentMounted={true}
|
||||
dividerProps={{
|
||||
className: 'opacity-50',
|
||||
}}
|
||||
defaultExpandedKeys={['config', 'cron']}
|
||||
>
|
||||
<AccordionItem
|
||||
key="config"
|
||||
startContent={
|
||||
<Avatar color="default" radius="lg" fallback={<WrenchIcon />} />
|
||||
}
|
||||
indicator={<WrenchIcon />}
|
||||
title="Config"
|
||||
subtitle={getOptionsSubtitle(Object.keys(configOptions).length)}
|
||||
>
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.main || {}}
|
||||
optionsJson={configOptionsJsonString}
|
||||
setOptionsJson={setConfigOptionsJsonString}
|
||||
availableOptions={configFlags || []}
|
||||
isLocked={configOptionsLocked}
|
||||
setIsLocked={setConfigOptionsLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
key="cron"
|
||||
startContent={
|
||||
<Avatar color="warning" radius="lg" fallback={<ClockIcon />} />
|
||||
}
|
||||
indicator={<ClockIcon />}
|
||||
title="Cron"
|
||||
>
|
||||
<CronEditor expression={cronExpression} onChange={setCronExpression} />
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</OperationWindowContent>
|
||||
|
||||
<OperationWindowFooter>
|
||||
<TemplatesDropdown
|
||||
isDisabled={!!jsonError}
|
||||
operation="purge"
|
||||
onSelect={(groupedOptions, shouldMerge) => {
|
||||
startTransition(() => {
|
||||
if (shouldMerge) {
|
||||
if (groupedOptions.config)
|
||||
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2))
|
||||
} else if (groupedOptions.config)
|
||||
setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2))
|
||||
})
|
||||
}}
|
||||
getOptions={() => ({
|
||||
...configOptions,
|
||||
})}
|
||||
/>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{startPurgeMutation.isSuccess ? (
|
||||
<motion.div
|
||||
key="started-buttons"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="flex flex-1 gap-2"
|
||||
>
|
||||
<Dropdown shadow={platform() === 'windows' ? 'none' : undefined}>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
fullWidth={true}
|
||||
color="primary"
|
||||
size="lg"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
NEW PURGE
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu>
|
||||
<DropdownItem
|
||||
key="reset-paths"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setSource(undefined)
|
||||
setJsonError(null)
|
||||
startPurgeMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset Path
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="reset-options"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setConfigOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.config,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setCronExpression(null)
|
||||
setJsonError(null)
|
||||
startPurgeMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset Options
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="reset-all"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setConfigOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.config,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setConfigOptionsLocked(false)
|
||||
setCronExpression(null)
|
||||
setJsonError(null)
|
||||
setSource(undefined)
|
||||
startPurgeMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset All
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="start-button"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="flex flex-1"
|
||||
>
|
||||
<Button
|
||||
onPress={() => setTimeout(() => startPurgeMutation.mutate(), 100)}
|
||||
size="lg"
|
||||
fullWidth={true}
|
||||
type="button"
|
||||
color="primary"
|
||||
isDisabled={startPurgeMutation.isPending || !!jsonError || !source}
|
||||
isLoading={startPurgeMutation.isPending}
|
||||
endContent={buttonIcon}
|
||||
className="max-w-2xl gap-2"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<ButtonGroup variant="flat">
|
||||
<Tooltip content="Schedule task" placement="top" size="lg" color="foreground">
|
||||
<Button
|
||||
size="lg"
|
||||
type="button"
|
||||
color="primary"
|
||||
isIconOnly={true}
|
||||
onPress={() => {
|
||||
setTimeout(() => scheduleTaskMutation.mutate(), 100)
|
||||
}}
|
||||
>
|
||||
<ClockIcon className="size-6" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<CommandInfoButton
|
||||
content={`Removes a path and ALL of its contents.
|
||||
const HELP_CONTENT = `Removes a path and ALL of its contents.
|
||||
|
||||
Purge completely deletes the specified directory and everything inside it — files, subdirectories, everything. This is a destructive operation that cannot be undone.
|
||||
|
||||
@@ -381,10 +47,183 @@ Expand the accordion sections to customize your purge operation. Tap any chip on
|
||||
Tap the folder icon in the bottom bar to load or save option presets.
|
||||
|
||||
4. START THE PURGE
|
||||
Once a path is selected, tap "START PURGE" to begin. The entire directory and all its contents will be permanently deleted.`}
|
||||
Once a path is selected, tap "START PURGE" to begin. The entire directory and all its contents will be permanently deleted.`
|
||||
|
||||
export default function Purge() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { globalFlags, configFlags } = useFlags()
|
||||
|
||||
const [source, setSource] = useState<string | undefined>(
|
||||
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
|
||||
)
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
jsonError,
|
||||
setJsonError,
|
||||
groups: optionGroups,
|
||||
applyTemplate,
|
||||
getMergedOptions,
|
||||
resetJson,
|
||||
resetLocks,
|
||||
} = useOptionGroups({
|
||||
groups: [{ key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config }],
|
||||
})
|
||||
const configGroup = optionGroups.config
|
||||
|
||||
const buildArgs = () => ({
|
||||
sources: [source!],
|
||||
options: {
|
||||
config: configGroup.options,
|
||||
},
|
||||
})
|
||||
|
||||
const startPurgeMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!source) {
|
||||
throw new Error('Please select a source path')
|
||||
}
|
||||
|
||||
return startPurge(buildArgs())
|
||||
},
|
||||
onSuccess: async () => {
|
||||
if (cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
onError: onErrorDialog('Purge', 'Failed to start purge', {
|
||||
log: ['[Purge] Failed to start purge:'],
|
||||
}),
|
||||
})
|
||||
|
||||
const scheduleTaskMutation = useScheduleTask({
|
||||
operation: 'purge',
|
||||
cronExpression,
|
||||
validate: () => {
|
||||
if (!source) {
|
||||
throw new Error('Please select a source path to purge')
|
||||
}
|
||||
},
|
||||
buildArgs,
|
||||
})
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
if (startPurgeMutation.isPending) return 'STARTING...'
|
||||
if (!source) return 'Please select a source path'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
if (cronExpression) return 'START AND SCHEDULE PURGE'
|
||||
return 'START PURGE'
|
||||
}, [startPurgeMutation.isPending, source, jsonError, cronExpression])
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (startPurgeMutation.isPending) return
|
||||
if (!source) return <FoldersIcon className="w-5 h-5" />
|
||||
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
|
||||
return <PlayIcon className="w-5 h-5 fill-current" />
|
||||
}, [startPurgeMutation.isPending, source, jsonError])
|
||||
|
||||
const accordionItems = useMemo<OptionsAccordionItemDef[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'config',
|
||||
category: 'config',
|
||||
subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length),
|
||||
children: (
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.main || {}}
|
||||
optionsJson={configGroup.jsonString}
|
||||
setOptionsJson={configGroup.setJsonString}
|
||||
availableOptions={configFlags || []}
|
||||
isLocked={configGroup.locked}
|
||||
setIsLocked={configGroup.setLocked}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cron',
|
||||
category: 'cron',
|
||||
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
|
||||
},
|
||||
],
|
||||
[configGroup, globalFlags, configFlags, cronExpression]
|
||||
)
|
||||
|
||||
const handleStart = useCallback(() => startPurgeMutation.mutate(), [startPurgeMutation.mutate])
|
||||
|
||||
const handleSchedule = useCallback(
|
||||
() => scheduleTaskMutation.mutate(),
|
||||
[scheduleTaskMutation.mutate]
|
||||
)
|
||||
|
||||
const handleResetPaths = useCallback(() => {
|
||||
startTransition(() => {
|
||||
setSource(undefined)
|
||||
setJsonError(null)
|
||||
startPurgeMutation.reset()
|
||||
})
|
||||
}, [setJsonError, startPurgeMutation.reset])
|
||||
|
||||
const handleResetOptions = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
setCronExpression(null)
|
||||
startPurgeMutation.reset()
|
||||
})
|
||||
}, [resetJson, startPurgeMutation.reset])
|
||||
|
||||
const handleResetAll = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
resetLocks()
|
||||
setCronExpression(null)
|
||||
setSource(undefined)
|
||||
startPurgeMutation.reset()
|
||||
})
|
||||
}, [resetJson, resetLocks, startPurgeMutation.reset])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10">
|
||||
{/* Main Content */}
|
||||
<OperationWindowContent>
|
||||
{/* Path Display */}
|
||||
<PathField
|
||||
path={source || ''}
|
||||
setPath={setSource}
|
||||
label="Path"
|
||||
placeholder="Enter a remote:/path to purge"
|
||||
showPicker={true}
|
||||
allowedKeys={PATH_ALLOWED_KEYS}
|
||||
showFiles={false}
|
||||
/>
|
||||
|
||||
<OptionsAccordion
|
||||
defaultExpandedKeys={DEFAULT_EXPANDED_KEYS}
|
||||
items={accordionItems}
|
||||
/>
|
||||
</OperationWindowContent>
|
||||
|
||||
<OperationWindowFooter>
|
||||
<OperationFooter
|
||||
operation="purge"
|
||||
templatesDisabled={!!jsonError}
|
||||
onTemplateSelect={applyTemplate}
|
||||
getTemplateOptions={getMergedOptions}
|
||||
startIsSuccess={startPurgeMutation.isSuccess}
|
||||
startIsPending={startPurgeMutation.isPending}
|
||||
onStart={handleStart}
|
||||
onSchedule={handleSchedule}
|
||||
startBlocked={!!jsonError || !source}
|
||||
buttonText={buttonText}
|
||||
buttonIcon={buttonIcon}
|
||||
newLabel="NEW PURGE"
|
||||
showViewTransfers={false}
|
||||
resetPathsLabel="Reset Path"
|
||||
onResetPaths={handleResetPaths}
|
||||
onResetOptions={handleResetOptions}
|
||||
onResetAll={handleResetAll}
|
||||
helpContent={HELP_CONTENT}
|
||||
/>
|
||||
<CommandsDropdown currentCommand="purge" />
|
||||
</ButtonGroup>
|
||||
</OperationWindowFooter>
|
||||
</div>
|
||||
)
|
||||
|
||||
+11
-9
@@ -8,6 +8,7 @@ import { formatDistance } from 'date-fns'
|
||||
import { AlertCircleIcon, Clock7Icon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { buildReadablePath } from '../../lib/format'
|
||||
import { useNow } from '../../lib/hooks'
|
||||
import { useHostStore } from '../../store/host'
|
||||
import type { ScheduledTask } from '../../types/schedules'
|
||||
import CommandsDropdown from '../components/CommandsDropdown'
|
||||
@@ -69,10 +70,14 @@ function TaskCard({
|
||||
}
|
||||
}, [task.name, isEditingName])
|
||||
|
||||
// The card's time-derived values are anchored to this tick — without it the memos freeze at
|
||||
// their last dep change (e.g. a past occurrence kept showing as the "next run" forever).
|
||||
const now = useNow()
|
||||
|
||||
const nextRun = useMemo(() => {
|
||||
const parsed = CronExpressionParser.parse(task.cron)
|
||||
const parsed = CronExpressionParser.parse(task.cron, { currentDate: new Date(now) })
|
||||
return parsed.hasNext() ? parsed.next().toDate() : null
|
||||
}, [task.cron])
|
||||
}, [task.cron, now])
|
||||
|
||||
const source = useMemo(
|
||||
() => ('source' in task.args ? task.args.source : task.args.sources[0]),
|
||||
@@ -81,24 +86,24 @@ function TaskCard({
|
||||
|
||||
const nextRunLabel = useMemo(() => {
|
||||
if (nextRun) {
|
||||
const distance = formatDistance(nextRun, new Date(), { addSuffix: true })
|
||||
const distance = formatDistance(nextRun, new Date(now), { addSuffix: true })
|
||||
return distance.charAt(0).toUpperCase() + distance.slice(1)
|
||||
}
|
||||
return 'Never'
|
||||
}, [nextRun])
|
||||
}, [nextRun, now])
|
||||
|
||||
const lastRunLabel = useMemo(() => {
|
||||
if (task.isRunning) {
|
||||
return 'Running now'
|
||||
}
|
||||
if (task.lastRun) {
|
||||
const distance = formatDistance(new Date(task.lastRun), new Date(), {
|
||||
const distance = formatDistance(new Date(task.lastRun), new Date(now), {
|
||||
addSuffix: true,
|
||||
})
|
||||
return distance.charAt(0).toUpperCase() + distance.slice(1)
|
||||
}
|
||||
return 'Never'
|
||||
}, [task.isRunning, task.lastRun])
|
||||
}, [task.isRunning, task.lastRun, now])
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -109,9 +114,6 @@ function TaskCard({
|
||||
onPress={() => onOpenDrawer(task)}
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
// border: '1px solid #e0e0e070',
|
||||
// borderBottom: '1px solid #e0e0e070',
|
||||
// padding: '0.5rem',
|
||||
}}
|
||||
className="p-2 border-b border-divider"
|
||||
>
|
||||
|
||||
+47
-17
@@ -12,7 +12,6 @@ import {
|
||||
SelectItem,
|
||||
Tooltip,
|
||||
} from '@heroui/react'
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { onErrorDialog } from '../../lib/errors'
|
||||
import { getOptionsSubtitle } from '../../lib/flags'
|
||||
import { useFlags } from '../../lib/hooks'
|
||||
import { startServe } from '../../lib/rclone/api'
|
||||
@@ -90,14 +90,9 @@ export default function Serve() {
|
||||
...(vfsOptions as Record<string, FlagValue>),
|
||||
})
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('[Serve] Failed to start serve:', error)
|
||||
Sentry.captureException(error)
|
||||
await message(error instanceof Error ? error.message : 'Failed to start serve', {
|
||||
title: 'Serve',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
onError: onErrorDialog('Serve', 'Failed to start serve', {
|
||||
log: ['[Serve] Failed to start serve:'],
|
||||
}),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
@@ -290,19 +285,54 @@ export default function Serve() {
|
||||
startTransition(() => {
|
||||
if (shouldMerge) {
|
||||
if (groupedOptions.serve && type)
|
||||
setServeOptionsJsonString(JSON.stringify({ ...serveOptions, ...groupedOptions.serve[type] }, null, 2))
|
||||
setServeOptionsJsonString(
|
||||
JSON.stringify(
|
||||
{ ...serveOptions, ...groupedOptions.serve[type] },
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
if (groupedOptions.vfs)
|
||||
setVfsOptionsJsonString(JSON.stringify({ ...vfsOptions, ...groupedOptions.vfs }, null, 2))
|
||||
setVfsOptionsJsonString(
|
||||
JSON.stringify(
|
||||
{ ...vfsOptions, ...groupedOptions.vfs },
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
if (groupedOptions.filter)
|
||||
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2))
|
||||
setFilterOptionsJsonString(
|
||||
JSON.stringify(
|
||||
{ ...filterOptions, ...groupedOptions.filter },
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
if (groupedOptions.config)
|
||||
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2))
|
||||
setConfigOptionsJsonString(
|
||||
JSON.stringify(
|
||||
{ ...configOptions, ...groupedOptions.config },
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
} else {
|
||||
if (groupedOptions.serve && type)
|
||||
setServeOptionsJsonString(JSON.stringify(groupedOptions.serve[type], null, 2))
|
||||
if (groupedOptions.vfs) setVfsOptionsJsonString(JSON.stringify(groupedOptions.vfs, null, 2))
|
||||
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2))
|
||||
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2))
|
||||
setServeOptionsJsonString(
|
||||
JSON.stringify(groupedOptions.serve[type], null, 2)
|
||||
)
|
||||
if (groupedOptions.vfs)
|
||||
setVfsOptionsJsonString(
|
||||
JSON.stringify(groupedOptions.vfs, null, 2)
|
||||
)
|
||||
if (groupedOptions.filter)
|
||||
setFilterOptionsJsonString(
|
||||
JSON.stringify(groupedOptions.filter, null, 2)
|
||||
)
|
||||
if (groupedOptions.config)
|
||||
setConfigOptionsJsonString(
|
||||
JSON.stringify(groupedOptions.config, null, 2)
|
||||
)
|
||||
}
|
||||
})
|
||||
}}
|
||||
|
||||
@@ -18,11 +18,13 @@ import { useMemo } from 'react'
|
||||
import rclone from '../../../lib/rclone/client'
|
||||
import { getDefaultPaths } from '../../../lib/rclone/common'
|
||||
import { DOUBLE_BACKSLASH_REGEX } from '../../../lib/rclone/constants'
|
||||
import { useHostStore } from '../../../store/host'
|
||||
import { selectActiveConfigFile, useHostStore } from '../../../store/host'
|
||||
import { usePersistedStore } from '../../../store/persisted'
|
||||
import BaseSection from './BaseSection'
|
||||
|
||||
export default function AboutSection() {
|
||||
const currentConfig = useHostStore((state) => state.activeConfigFile)
|
||||
const currentConfig = useHostStore(selectActiveConfigFile)
|
||||
const rclonePath = usePersistedStore((state) => state.rclonePath)
|
||||
|
||||
const defaultPathsQuery = useQuery({
|
||||
queryKey: ['about', 'defaultPaths'],
|
||||
@@ -74,6 +76,7 @@ export default function AboutSection() {
|
||||
},
|
||||
paths: defaultPathsQuery.data,
|
||||
dirs: dirsQuery.data,
|
||||
rcloneBinary: rclonePath,
|
||||
config: {
|
||||
id: currentConfig?.id,
|
||||
label: currentConfig?.label,
|
||||
@@ -88,6 +91,7 @@ export default function AboutSection() {
|
||||
currentConfig,
|
||||
defaultPathsQuery.data,
|
||||
dirsQuery.data,
|
||||
rclonePath,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -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 & 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>
|
||||
)
|
||||
}
|
||||
@@ -28,11 +28,12 @@ import {
|
||||
Trash2Icon,
|
||||
} from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { onErrorDialog } from '../../../lib/errors'
|
||||
import { removeConfigPassword, setConfigPassword } from '../../../lib/rclone/api'
|
||||
import { promptForConfigPassword, restartActiveRclone } from '../../../lib/rclone/cli'
|
||||
import rclone from '../../../lib/rclone/client'
|
||||
import { getConfigPath } from '../../../lib/rclone/common'
|
||||
import { useHostStore } from '../../../store/host'
|
||||
import { selectActiveConfigFile, useHostStore } from '../../../store/host'
|
||||
import { usePersistedStore } from '../../../store/persisted'
|
||||
import type { ConfigFile } from '../../../types/config'
|
||||
import ConfigCreateDrawer from '../../components/ConfigCreateDrawer'
|
||||
@@ -44,7 +45,7 @@ export default function ConfigSection() {
|
||||
const licenseValid = usePersistedStore((state) => state.licenseValid)
|
||||
|
||||
const configFiles = useHostStore((state) => state.configFiles)
|
||||
const activeConfigFile = useHostStore((state) => state.activeConfigFile)
|
||||
const activeConfigFile = useHostStore(selectActiveConfigFile)
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
@@ -94,14 +95,11 @@ export default function ConfigSection() {
|
||||
await queryClient.cancelQueries()
|
||||
await queryClient.resetQueries()
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('[switchConfig] failed to switch config', error)
|
||||
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
|
||||
title: 'Switch Config',
|
||||
kind: 'error',
|
||||
onError: onErrorDialog('Switch Config', undefined, {
|
||||
okLabel: 'OK',
|
||||
})
|
||||
},
|
||||
capture: false,
|
||||
log: ['[switchConfig] failed to switch config'],
|
||||
}),
|
||||
})
|
||||
|
||||
const locateConfigMutation = useMutation({
|
||||
@@ -109,14 +107,11 @@ export default function ConfigSection() {
|
||||
const configPath = await getConfigPath({ id: id, validate: true })
|
||||
await revealItemInDir(configPath)
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('[locateConfig] failed to locate config', error)
|
||||
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
|
||||
title: 'Failed to locate config',
|
||||
kind: 'error',
|
||||
onError: onErrorDialog('Failed to locate config', undefined, {
|
||||
okLabel: 'OK',
|
||||
})
|
||||
},
|
||||
capture: false,
|
||||
log: ['[locateConfig] failed to locate config'],
|
||||
}),
|
||||
})
|
||||
|
||||
const exportConfigMutation = useMutation({
|
||||
@@ -142,14 +137,11 @@ export default function ConfigSection() {
|
||||
|
||||
await writeTextFile(exportPath, text)
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('[exportConfig] failed to export config', error)
|
||||
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
|
||||
title: 'Failed to export config',
|
||||
kind: 'error',
|
||||
onError: onErrorDialog('Failed to export config', undefined, {
|
||||
okLabel: 'OK',
|
||||
})
|
||||
},
|
||||
capture: false,
|
||||
log: ['[exportConfig] failed to export config'],
|
||||
}),
|
||||
})
|
||||
|
||||
const removePasswordMutation = useMutation({
|
||||
@@ -178,14 +170,11 @@ export default function ConfigSection() {
|
||||
|
||||
await removeConfigPassword()
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('[removePassword] failed to remove password', error)
|
||||
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
|
||||
title: 'Config Encryption',
|
||||
kind: 'error',
|
||||
onError: onErrorDialog('Config Encryption', undefined, {
|
||||
okLabel: 'OK',
|
||||
})
|
||||
},
|
||||
capture: false,
|
||||
log: ['[removePassword] failed to remove password'],
|
||||
}),
|
||||
})
|
||||
|
||||
const setPasswordMutation = useMutation({
|
||||
@@ -207,14 +196,11 @@ export default function ConfigSection() {
|
||||
persist: Boolean(activeConfigFile.pass),
|
||||
})
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('[setPassword] failed to set password', error)
|
||||
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
|
||||
title: 'Config Encryption',
|
||||
kind: 'error',
|
||||
onError: onErrorDialog('Config Encryption', undefined, {
|
||||
okLabel: 'OK',
|
||||
})
|
||||
},
|
||||
capture: false,
|
||||
log: ['[setPassword] failed to set password'],
|
||||
}),
|
||||
})
|
||||
|
||||
const savePasswordMutation = useMutation({
|
||||
@@ -253,14 +239,11 @@ export default function ConfigSection() {
|
||||
okLabel: 'OK',
|
||||
})
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('[savePasswordCommand] failed to save password command', error)
|
||||
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
|
||||
title: 'Config Password',
|
||||
kind: 'error',
|
||||
onError: onErrorDialog('Config Password', undefined, {
|
||||
okLabel: 'OK',
|
||||
})
|
||||
},
|
||||
capture: false,
|
||||
log: ['[savePasswordCommand] failed to save password command'],
|
||||
}),
|
||||
})
|
||||
|
||||
const savePasswordCommandMutation = useMutation({
|
||||
@@ -307,14 +290,11 @@ export default function ConfigSection() {
|
||||
okLabel: 'OK',
|
||||
})
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('[savePasswordCommand] failed to save password command', error)
|
||||
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
|
||||
title: 'Config Password',
|
||||
kind: 'error',
|
||||
onError: onErrorDialog('Config Password', undefined, {
|
||||
okLabel: 'OK',
|
||||
})
|
||||
},
|
||||
capture: false,
|
||||
log: ['[savePasswordCommand] failed to save password command'],
|
||||
}),
|
||||
})
|
||||
|
||||
const removeSavedPasswordMutation = useMutation({
|
||||
@@ -351,14 +331,11 @@ export default function ConfigSection() {
|
||||
okLabel: 'OK',
|
||||
})
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('[removeSavedPassword] failed to remove saved password', error)
|
||||
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
|
||||
title: 'Config Password',
|
||||
kind: 'error',
|
||||
onError: onErrorDialog('Config Password', undefined, {
|
||||
okLabel: 'OK',
|
||||
})
|
||||
},
|
||||
capture: false,
|
||||
log: ['[removeSavedPassword] failed to remove saved password'],
|
||||
}),
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -515,9 +492,7 @@ function ConfigCard({
|
||||
|
||||
const disabled = ['enable']
|
||||
|
||||
if (configFile.passCommand) {
|
||||
disabled.push('save-password', 'save-password-command')
|
||||
} else if (configFile.pass) {
|
||||
if (configFile.passCommand || configFile.pass) {
|
||||
disabled.push('save-password', 'save-password-command')
|
||||
} else {
|
||||
disabled.push('remove-password')
|
||||
@@ -718,22 +693,50 @@ function ConfigCard({
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!configFile.sync) {
|
||||
// validate: false so a manually-deleted
|
||||
// directory doesn't throw before we can still
|
||||
// clean up the store entry.
|
||||
const path = await getConfigPath({
|
||||
id: configFile.id!,
|
||||
validate: true,
|
||||
validate: false,
|
||||
})
|
||||
|
||||
await remove(path.replace('rclone.conf', ''), {
|
||||
recursive: true,
|
||||
})
|
||||
try {
|
||||
await remove(
|
||||
path.replace('rclone.conf', ''),
|
||||
{ recursive: true }
|
||||
)
|
||||
} catch (removeError) {
|
||||
// An already-gone directory is fine — fall
|
||||
// through and still remove the store entry.
|
||||
const detail =
|
||||
removeError instanceof Error
|
||||
? removeError.message
|
||||
: String(removeError)
|
||||
if (
|
||||
!/no such file|not found|cannot find|does not exist/i.test(
|
||||
detail
|
||||
)
|
||||
) {
|
||||
throw removeError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (activeConfigFile?.id === configFile.id) {
|
||||
useHostStore.getState().setActiveConfigFile('default')
|
||||
useHostStore
|
||||
.getState()
|
||||
.setActiveConfigFile('default')
|
||||
}
|
||||
|
||||
useHostStore.getState().removeConfigFile(configFile.id!)
|
||||
useHostStore
|
||||
.getState()
|
||||
.removeConfigFile(configFile.id!)
|
||||
} catch (error) {
|
||||
await onErrorDialog('Delete Config')(error)
|
||||
}
|
||||
}, 100)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Button, Checkbox, Chip, Input, Select, SelectItem } from '@heroui/react
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import { useMutation, useQuery } from '@tanstack/react-query'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { disable, enable } from '@tauri-apps/plugin-autostart'
|
||||
import { ask, message } from '@tauri-apps/plugin-dialog'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
@@ -10,6 +9,7 @@ import { platform } from '@tauri-apps/plugin-os'
|
||||
import { type Update, check } from '@tauri-apps/plugin-updater'
|
||||
import { EyeIcon } from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { RELAUNCH_APP, emitToMain } from '../../../lib/events'
|
||||
import notify from '../../../lib/notify'
|
||||
import { usePersistedStore } from '../../../store/persisted'
|
||||
import BaseSection from './BaseSection'
|
||||
@@ -130,7 +130,7 @@ export default function GeneralSection() {
|
||||
return
|
||||
}
|
||||
|
||||
await getCurrentWindow().emit('relaunch-app')
|
||||
await emitToMain(RELAUNCH_APP)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -234,7 +234,11 @@ export default function GeneralSection() {
|
||||
label="Tray Theme"
|
||||
selectedKeys={[appearance.tray]}
|
||||
onSelectionChange={(keys) => {
|
||||
const value = Array.from(keys)[0] as 'light' | 'dark' | 'system' | 'color'
|
||||
const value = Array.from(keys)[0] as
|
||||
| 'light'
|
||||
| 'dark'
|
||||
| 'system'
|
||||
| 'color'
|
||||
usePersistedStore.setState((state) => ({
|
||||
appearance: { ...state.appearance, tray: value },
|
||||
}))
|
||||
|
||||
@@ -4,13 +4,13 @@ import { ask, message } from '@tauri-apps/plugin-dialog'
|
||||
import { PlusIcon, RefreshCcwIcon, Trash2Icon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { type Host, LABEL_FOR_OS, LOCAL_HOST_ID, getHostInfo } from '../../../lib/hosts'
|
||||
import { usePersistedStore } from '../../../store/persisted'
|
||||
import { useCurrentHost, usePersistedStore } from '../../../store/persisted'
|
||||
import HostAddDrawer from '../../components/HostAddDrawer'
|
||||
import BaseSection from './BaseSection'
|
||||
|
||||
export default function HostsSection() {
|
||||
const hosts = usePersistedStore((state) => state.hosts)
|
||||
const currentHost = usePersistedStore((state) => state.currentHost)
|
||||
const currentHost = useCurrentHost()
|
||||
|
||||
const [isCreateDrawerOpen, setIsCreateDrawerOpen] = useState(false)
|
||||
|
||||
@@ -89,9 +89,7 @@ function HostCard({
|
||||
return
|
||||
}
|
||||
|
||||
usePersistedStore.setState({
|
||||
currentHost: host,
|
||||
})
|
||||
usePersistedStore.getState().setCurrentHost(host.id)
|
||||
},
|
||||
onError: () => {
|
||||
message('Failed to change host. Please try again.', {
|
||||
@@ -119,11 +117,6 @@ function HostCard({
|
||||
usePersistedStore.setState((state) => ({
|
||||
hosts: state.hosts.map((h) => (h.id === host.id ? { ...h, ...hostInfo } : h)),
|
||||
}))
|
||||
if (isActive) {
|
||||
usePersistedStore.setState((state) => ({
|
||||
currentHost: { ...state.currentHost!, ...hostInfo },
|
||||
}))
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
message('Failed to update host. Please try again.', {
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger,
|
||||
Spinner,
|
||||
Input,
|
||||
Spinner,
|
||||
} from '@heroui/react'
|
||||
import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { ask, message } from '@tauri-apps/plugin-dialog'
|
||||
@@ -23,7 +23,9 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { type ReactNode, startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { onErrorDialog } from '../../../lib/errors'
|
||||
import { formatBytes } from '../../../lib/format'
|
||||
import { remoteConfigQueryOptions } from '../../../lib/hooks'
|
||||
import rclone from '../../../lib/rclone/client'
|
||||
import { SUPPORTS_ABOUT } from '../../../lib/rclone/constants'
|
||||
import { usePersistedStore } from '../../../store/persisted'
|
||||
@@ -53,42 +55,38 @@ export default function RemotesSection() {
|
||||
|
||||
const remoteConfigQueries = useQueries({
|
||||
queries: remotes.map((remote) => ({
|
||||
queryKey: ['remotes', remote, 'config', 'sortable'],
|
||||
queryFn: async () => {
|
||||
const config = await rclone('/config/get', {
|
||||
params: { query: { name: remote } },
|
||||
})
|
||||
return { remote, type: config?.type ?? null }
|
||||
},
|
||||
...remoteConfigQueryOptions(remote),
|
||||
staleTime: 1000 * 60,
|
||||
})),
|
||||
})
|
||||
|
||||
const sortedRemotes = useMemo(
|
||||
() =>
|
||||
[...remotes].sort((a, b) => {
|
||||
const configA = remoteConfigQueries.find((q) => q.data?.remote === a)?.data
|
||||
const configB = remoteConfigQueries.find((q) => q.data?.remote === b)?.data
|
||||
const sortedRemotes = useMemo(() => {
|
||||
// useQueries preserves input order, so remoteConfigQueries[i] corresponds to remotes[i].
|
||||
const typeByRemote = new Map<string, string | null>()
|
||||
remotes.forEach((remote, i) => {
|
||||
typeByRemote.set(remote, remoteConfigQueries[i]?.data?.type ?? null)
|
||||
})
|
||||
|
||||
const aSupportsAbout = configA?.type ? SUPPORTS_ABOUT.includes(configA.type) : false
|
||||
const bSupportsAbout = configB?.type ? SUPPORTS_ABOUT.includes(configB.type) : false
|
||||
return [...remotes].sort((a, b) => {
|
||||
const aType = typeByRemote.get(a)
|
||||
const bType = typeByRemote.get(b)
|
||||
|
||||
const aSupportsAbout = aType ? SUPPORTS_ABOUT.includes(aType) : false
|
||||
const bSupportsAbout = bType ? SUPPORTS_ABOUT.includes(bType) : false
|
||||
|
||||
if (aSupportsAbout && !bSupportsAbout) return -1
|
||||
if (!aSupportsAbout && bSupportsAbout) return 1
|
||||
|
||||
return a.localeCompare(b)
|
||||
}),
|
||||
[remotes, remoteConfigQueries]
|
||||
)
|
||||
})
|
||||
}, [remotes, remoteConfigQueries])
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
const filteredRemotes = useMemo(
|
||||
() =>
|
||||
searchQuery
|
||||
? sortedRemotes.filter((r) =>
|
||||
r.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
? sortedRemotes.filter((r) => r.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
: sortedRemotes,
|
||||
[sortedRemotes, searchQuery]
|
||||
)
|
||||
@@ -116,13 +114,10 @@ export default function RemotesSection() {
|
||||
...(old ?? []).filter((r) => r !== remote),
|
||||
])
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Failed to delete remote:', error)
|
||||
await message(error instanceof Error ? error.message : 'Unknown error occurred', {
|
||||
title: 'Could not delete remote',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
onError: onErrorDialog('Could not delete remote', 'Unknown error occurred', {
|
||||
capture: false,
|
||||
log: ['Failed to delete remote:'],
|
||||
}),
|
||||
})
|
||||
|
||||
const Placeholder = useMemo(() => {
|
||||
@@ -334,18 +329,7 @@ function RemoteCard({
|
||||
onConfigPress: () => void
|
||||
onDeletePress: () => void
|
||||
}) {
|
||||
const { data: remoteConfigData } = useQuery({
|
||||
queryKey: ['remotes', remote, 'config'],
|
||||
queryFn: async () => {
|
||||
return await rclone('/config/get', {
|
||||
params: {
|
||||
query: {
|
||||
name: remote,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
const { data: remoteConfigData } = useQuery(remoteConfigQueryOptions(remote))
|
||||
|
||||
const type = useMemo(() => remoteConfigData?.type ?? null, [remoteConfigData?.type])
|
||||
const provider = useMemo(() => remoteConfigData?.provider ?? null, [remoteConfigData?.provider])
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
InfoIcon,
|
||||
KeyboardIcon,
|
||||
MedalIcon,
|
||||
PackageIcon,
|
||||
SatelliteDishIcon,
|
||||
ServerIcon,
|
||||
TabletSmartphoneIcon,
|
||||
@@ -21,8 +22,9 @@ import { useSearchParams } from 'react-router-dom'
|
||||
import { LOCAL_HOST_ID } from '../../../lib/hosts'
|
||||
import rclone from '../../../lib/rclone/client'
|
||||
import { useStore } from '../../../store/memory'
|
||||
import { usePersistedStore } from '../../../store/persisted'
|
||||
import { useCurrentHost, usePersistedStore } from '../../../store/persisted'
|
||||
import AboutSection from './AboutSection'
|
||||
import BinarySection from './BinarySection'
|
||||
import ConfigSection from './ConfigSection'
|
||||
import GeneralSection from './GeneralSection'
|
||||
import HostsSection from './HostsSection'
|
||||
@@ -35,7 +37,7 @@ import ToolbarSection from './ToolbarSection'
|
||||
export default function Settings() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const settingsPass = usePersistedStore((state) => state.settingsPass)
|
||||
const currentHost = usePersistedStore((state) => state.currentHost)
|
||||
const currentHost = useCurrentHost()
|
||||
const isRestartingRclone = useStore((state) => state.isRestartingRclone)
|
||||
const isLocalHost = useMemo(() => currentHost?.id === LOCAL_HOST_ID, [currentHost?.id])
|
||||
|
||||
@@ -124,11 +126,7 @@ export default function Settings() {
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
onPress={checkPassword}
|
||||
data-focus-visible="false"
|
||||
color="primary"
|
||||
>
|
||||
<Button onPress={checkPassword} data-focus-visible="false" color="primary">
|
||||
Open
|
||||
</Button>
|
||||
</div>
|
||||
@@ -234,6 +232,34 @@ export default function Settings() {
|
||||
>
|
||||
<ConfigSection />
|
||||
</Tab>
|
||||
<Tab
|
||||
key="binary"
|
||||
title={
|
||||
<Tooltip
|
||||
content={
|
||||
currentHost?.id !== 'local'
|
||||
? 'Rclone settings are only available when using your local machine, not a remote host'
|
||||
: undefined
|
||||
}
|
||||
isDisabled={currentHost?.id === 'local'}
|
||||
placement="right"
|
||||
size="lg"
|
||||
color="foreground"
|
||||
className="max-w-48"
|
||||
offset={90}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<PackageIcon className="w-5 h-5" />
|
||||
<span>Binary</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
data-focus-visible="false"
|
||||
isDisabled={currentHost?.id !== 'local'}
|
||||
className="w-full max-h-screen p-0 overflow-scroll overscroll-none"
|
||||
>
|
||||
<BinarySection />
|
||||
</Tab>
|
||||
<Tab
|
||||
key="proxy"
|
||||
title={
|
||||
|
||||
+300
-607
@@ -1,631 +1,51 @@
|
||||
import {
|
||||
Accordion,
|
||||
AccordionItem,
|
||||
Avatar,
|
||||
Button,
|
||||
ButtonGroup,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownMenu,
|
||||
DropdownTrigger,
|
||||
Tooltip,
|
||||
} from '@heroui/react'
|
||||
import * as Sentry from '@sentry/browser'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { ask, message } from '@tauri-apps/plugin-dialog'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import cronstrue from 'cronstrue'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import {
|
||||
AlertOctagonIcon,
|
||||
ClockIcon,
|
||||
EyeIcon,
|
||||
FilterIcon,
|
||||
FolderSyncIcon,
|
||||
FoldersIcon,
|
||||
PlayIcon,
|
||||
ServerIcon,
|
||||
WrenchIcon,
|
||||
} from 'lucide-react'
|
||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
||||
import { AlertOctagonIcon, FoldersIcon, PlayIcon } from 'lucide-react'
|
||||
import { startTransition, useCallback, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { onErrorDialog } from '../../lib/errors'
|
||||
import { getOptionsSubtitle } from '../../lib/flags'
|
||||
import { useFlags } from '../../lib/hooks'
|
||||
import notify from '../../lib/notify'
|
||||
import { startDryRun, startSync } from '../../lib/rclone/api'
|
||||
import { RCLONE_CONFIG_DEFAULTS } from '../../lib/rclone/constants'
|
||||
import { openWindow } from '../../lib/window'
|
||||
import { useHostStore } from '../../store/host'
|
||||
import type { FlagValue } from '../../types/rclone'
|
||||
import CommandInfoButton from '../components/CommandInfoButton'
|
||||
import CommandsDropdown from '../components/CommandsDropdown'
|
||||
import CronEditor from '../components/CronEditor'
|
||||
import OperationWindowContent from '../components/OperationWindowContent'
|
||||
import OperationWindowFooter from '../components/OperationWindowFooter'
|
||||
import OptionsSection from '../components/OptionsSection'
|
||||
import { PathFinder } from '../components/PathFinder'
|
||||
import RemoteOptionsSection from '../components/RemoteOptionsSection'
|
||||
import ShowMoreOptionsBanner from '../components/ShowMoreOptionsBanner'
|
||||
import TemplatesDropdown from '../components/TemplatesDropdown'
|
||||
import OperationFooter from '../components/operation/OperationFooter'
|
||||
import OptionsAccordion, {
|
||||
type OptionsAccordionItemDef,
|
||||
} from '../components/operation/OptionsAccordion'
|
||||
import { useOperationDryRun } from '../components/operation/useOperationDryRun'
|
||||
import { useOptionGroups } from '../components/operation/useOptionGroups'
|
||||
import { useScheduleTask } from '../components/operation/useScheduleTask'
|
||||
|
||||
export default function Sync() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { globalFlags, filterFlags, configFlags, syncFlags } = useFlags()
|
||||
const PATH_ALLOWED_KEYS: ('LOCAL_FS' | 'FAVORITES' | 'REMOTES')[] = [
|
||||
'LOCAL_FS',
|
||||
'REMOTES',
|
||||
'FAVORITES',
|
||||
]
|
||||
|
||||
const [source, setSource] = useState<string | undefined>(
|
||||
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
|
||||
)
|
||||
const [dest, setDest] = useState<string | undefined>(
|
||||
searchParams.get('initialDestination') ? searchParams.get('initialDestination')! : undefined
|
||||
)
|
||||
|
||||
const [jsonError, setJsonError] = useState<'sync' | 'filter' | 'config' | 'remote' | null>(null)
|
||||
|
||||
const [syncOptionsLocked, setSyncOptionsLocked] = useState(false)
|
||||
const [syncOptions, setSyncOptions] = useState<Record<string, FlagValue>>({})
|
||||
const [syncOptionsJsonString, setSyncOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const [filterOptionsLocked, setFilterOptionsLocked] = useState(false)
|
||||
const [filterOptions, setFilterOptions] = useState<Record<string, FlagValue>>({})
|
||||
const [filterOptionsJsonString, setFilterOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const [configOptionsLocked, setConfigOptionsLocked] = useState(false)
|
||||
const [configOptions, setConfigOptions] = useState<Record<string, FlagValue>>({})
|
||||
const [configOptionsJsonString, setConfigOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const [remoteOptionsLocked, setRemoteOptionsLocked] = useState(false)
|
||||
const [remoteOptions, setRemoteOptions] = useState<Record<string, Record<string, FlagValue>>>(
|
||||
{}
|
||||
)
|
||||
const [remoteOptionsJsonString, setRemoteOptionsJsonString] = useState<string>('{}')
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
|
||||
const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest])
|
||||
|
||||
const startSyncMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!source || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
|
||||
return startSync({
|
||||
source: source,
|
||||
destination: dest,
|
||||
options: {
|
||||
config: configOptions,
|
||||
sync: syncOptions,
|
||||
filter: filterOptions,
|
||||
remotes: remoteOptions,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Error starting sync:', error)
|
||||
Sentry.captureException(error)
|
||||
await message(error instanceof Error ? error.message : 'Failed to start sync', {
|
||||
title: 'Sync',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const scheduleTaskMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!source || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
|
||||
if (!cronExpression) {
|
||||
throw new Error('Please enter a cron expression')
|
||||
}
|
||||
|
||||
try {
|
||||
cronstrue.toString(cronExpression)
|
||||
} catch {
|
||||
throw new Error('Invalid cron expression')
|
||||
}
|
||||
|
||||
const name = await invoke<string | null>('prompt', {
|
||||
title: 'Schedule Name',
|
||||
message: 'Enter a name for this schedule',
|
||||
default: `New Schedule ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: '2-digit' })}`,
|
||||
})
|
||||
|
||||
if (!name) {
|
||||
throw new Error('Schedule name is required')
|
||||
}
|
||||
|
||||
useHostStore.getState().addScheduledTask({
|
||||
name,
|
||||
operation: 'sync',
|
||||
cron: cronExpression,
|
||||
args: {
|
||||
source,
|
||||
destination: dest,
|
||||
options: {
|
||||
config: configOptions,
|
||||
sync: syncOptions,
|
||||
filter: filterOptions,
|
||||
remotes: remoteOptions,
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await notify({
|
||||
title: 'Success',
|
||||
body: 'New schedule has been created',
|
||||
})
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Error scheduling task:', error)
|
||||
await message(error instanceof Error ? error.message : 'Failed to schedule task', {
|
||||
title: 'Schedule',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const dryRunMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!source || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
return startDryRun(() =>
|
||||
startSync({
|
||||
source,
|
||||
destination: dest,
|
||||
options: {
|
||||
config: { ...configOptions, dry_run: true },
|
||||
sync: syncOptions,
|
||||
filter: filterOptions,
|
||||
remotes: remoteOptions,
|
||||
},
|
||||
})
|
||||
)
|
||||
},
|
||||
onSuccess: async () => {
|
||||
const result = await ask(
|
||||
'Dry run started, you can check the results in the Transfers screen',
|
||||
{
|
||||
title: 'Preview (Dry Run)',
|
||||
kind: 'info',
|
||||
okLabel: 'Open Transfers',
|
||||
cancelLabel: 'OK',
|
||||
}
|
||||
)
|
||||
if (result) {
|
||||
await openWindow({ name: 'Transfers', url: '/transfers' })
|
||||
}
|
||||
},
|
||||
onError: async (error) => {
|
||||
console.error('Error starting dry run:', error)
|
||||
await message(error instanceof Error ? error.message : 'Failed to start dry run', {
|
||||
title: 'Dry Run',
|
||||
kind: 'error',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
if (startSyncMutation.isPending) return 'STARTING...'
|
||||
if (!source) return 'Please select a source path'
|
||||
if (!dest) return 'Please select a destination path'
|
||||
if (source === dest) return 'Source and destination cannot be the same'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
if (cronExpression) return 'START AND SCHEDULE SYNC'
|
||||
return 'START SYNC'
|
||||
}, [startSyncMutation.isPending, source, dest, jsonError, cronExpression])
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (startSyncMutation.isPending) return
|
||||
if (!source || !dest || source === dest) return <FoldersIcon className="w-5 h-5" />
|
||||
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
|
||||
return <PlayIcon className="w-5 h-5 fill-current" />
|
||||
}, [startSyncMutation.isPending, source, dest, jsonError])
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
setConfigOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.config, null, 2))
|
||||
setSyncOptionsJsonString(JSON.stringify(RCLONE_CONFIG_DEFAULTS.copy, null, 2))
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let step: 'sync' | 'filter' | 'config' | 'remote' = 'sync'
|
||||
try {
|
||||
const parsedSync = JSON.parse(syncOptionsJsonString) as Record<string, FlagValue>
|
||||
|
||||
step = 'filter'
|
||||
const parsedFilter = JSON.parse(filterOptionsJsonString) as Record<string, FlagValue>
|
||||
|
||||
step = 'config'
|
||||
const parsedConfig = JSON.parse(configOptionsJsonString) as Record<string, FlagValue>
|
||||
|
||||
step = 'remote'
|
||||
const outerRemote = JSON.parse(remoteOptionsJsonString) as Record<string, string>
|
||||
const parsedRemote: Record<string, Record<string, FlagValue>> = {}
|
||||
for (const [key, val] of Object.entries(outerRemote)) {
|
||||
parsedRemote[key] = JSON.parse(val) as Record<string, FlagValue>
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
setSyncOptions(parsedSync)
|
||||
setFilterOptions(parsedFilter)
|
||||
setConfigOptions(parsedConfig)
|
||||
setRemoteOptions(parsedRemote)
|
||||
setJsonError(null)
|
||||
})
|
||||
} catch (error) {
|
||||
setJsonError(step)
|
||||
console.error(`Error parsing ${step} options:`, error)
|
||||
}
|
||||
}, [
|
||||
syncOptionsJsonString,
|
||||
filterOptionsJsonString,
|
||||
configOptionsJsonString,
|
||||
remoteOptionsJsonString,
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10">
|
||||
{/* Main Content */}
|
||||
<OperationWindowContent>
|
||||
{/* Paths Display */}
|
||||
<PathFinder
|
||||
sourcePath={source}
|
||||
setSourcePath={setSource}
|
||||
destPath={dest}
|
||||
setDestPath={setDest}
|
||||
sourceOptions={{
|
||||
const SOURCE_OPTIONS = {
|
||||
label: 'Source',
|
||||
showPicker: true,
|
||||
placeholder:
|
||||
'Enter a remote:/path or local path, or tap to select a folder',
|
||||
placeholder: 'Enter a remote:/path or local path, or tap to select a folder',
|
||||
clearable: true,
|
||||
showFiles: true,
|
||||
allowedKeys: ['LOCAL_FS', 'REMOTES', 'FAVORITES'],
|
||||
}}
|
||||
destOptions={{
|
||||
allowedKeys: PATH_ALLOWED_KEYS,
|
||||
}
|
||||
|
||||
const DEST_OPTIONS = {
|
||||
label: 'Destination',
|
||||
showPicker: true,
|
||||
placeholder: 'Enter a remote:/path or local path',
|
||||
clearable: true,
|
||||
showFiles: false,
|
||||
allowedKeys: ['LOCAL_FS', 'REMOTES', 'FAVORITES'],
|
||||
}}
|
||||
/>
|
||||
allowedKeys: PATH_ALLOWED_KEYS,
|
||||
}
|
||||
|
||||
<div className="relative flex flex-col">
|
||||
<Accordion
|
||||
keepContentMounted={true}
|
||||
dividerProps={{
|
||||
className: 'opacity-50',
|
||||
}}
|
||||
>
|
||||
<AccordionItem
|
||||
key="sync"
|
||||
startContent={
|
||||
<Avatar color="success" radius="lg" fallback={<FolderSyncIcon />} />
|
||||
}
|
||||
indicator={<FolderSyncIcon />}
|
||||
title="Sync"
|
||||
subtitle={getOptionsSubtitle(Object.keys(syncOptions).length)}
|
||||
>
|
||||
<OptionsSection
|
||||
optionsJson={syncOptionsJsonString}
|
||||
setOptionsJson={setSyncOptionsJsonString}
|
||||
globalOptions={globalFlags?.main || {}}
|
||||
availableOptions={syncFlags || []}
|
||||
isLocked={syncOptionsLocked}
|
||||
setIsLocked={setSyncOptionsLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
key="filters"
|
||||
startContent={
|
||||
<Avatar color="danger" radius="lg" fallback={<FilterIcon />} />
|
||||
}
|
||||
indicator={<FilterIcon />}
|
||||
title="Filters"
|
||||
subtitle={getOptionsSubtitle(Object.keys(filterOptions).length)}
|
||||
>
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.filter || {}}
|
||||
optionsJson={filterOptionsJsonString}
|
||||
setOptionsJson={setFilterOptionsJsonString}
|
||||
availableOptions={filterFlags || []}
|
||||
isLocked={filterOptionsLocked}
|
||||
setIsLocked={setFilterOptionsLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
key="cron"
|
||||
startContent={
|
||||
<Avatar color="warning" radius="lg" fallback={<ClockIcon />} />
|
||||
}
|
||||
indicator={<ClockIcon />}
|
||||
title="Cron"
|
||||
>
|
||||
<CronEditor expression={cronExpression} onChange={setCronExpression} />
|
||||
</AccordionItem>
|
||||
<AccordionItem
|
||||
key="config"
|
||||
startContent={
|
||||
<Avatar color="default" radius="lg" fallback={<WrenchIcon />} />
|
||||
}
|
||||
indicator={<WrenchIcon />}
|
||||
title="Config"
|
||||
subtitle={getOptionsSubtitle(Object.keys(configOptions).length)}
|
||||
>
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.main || {}}
|
||||
optionsJson={configOptionsJsonString}
|
||||
setOptionsJson={setConfigOptionsJsonString}
|
||||
availableOptions={configFlags || []}
|
||||
isLocked={configOptionsLocked}
|
||||
setIsLocked={setConfigOptionsLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
|
||||
{selectedRemotes.length > 0 ? (
|
||||
<AccordionItem
|
||||
key={'remotes'}
|
||||
startContent={
|
||||
<Avatar
|
||||
className="bg-fuchsia-500"
|
||||
radius="lg"
|
||||
fallback={<ServerIcon />}
|
||||
/>
|
||||
}
|
||||
indicator={<ServerIcon />}
|
||||
title={'Remotes'}
|
||||
subtitle={getOptionsSubtitle(
|
||||
Object.values(remoteOptions).reduce(
|
||||
(acc, opts) => acc + Object.keys(opts).length,
|
||||
0
|
||||
)
|
||||
)}
|
||||
>
|
||||
<RemoteOptionsSection
|
||||
selectedRemotes={selectedRemotes}
|
||||
remoteOptionsJsonString={remoteOptionsJsonString}
|
||||
setRemoteOptionsJsonString={setRemoteOptionsJsonString}
|
||||
setRemoteOptionsLocked={setRemoteOptionsLocked}
|
||||
remoteOptionsLocked={remoteOptionsLocked}
|
||||
/>
|
||||
</AccordionItem>
|
||||
) : null}
|
||||
</Accordion>
|
||||
|
||||
<ShowMoreOptionsBanner />
|
||||
</div>
|
||||
</OperationWindowContent>
|
||||
|
||||
<OperationWindowFooter>
|
||||
<TemplatesDropdown
|
||||
isDisabled={!!jsonError}
|
||||
operation="sync"
|
||||
onSelect={(groupedOptions, shouldMerge) => {
|
||||
startTransition(() => {
|
||||
if (shouldMerge) {
|
||||
if (groupedOptions.sync)
|
||||
setSyncOptionsJsonString(JSON.stringify({ ...syncOptions, ...groupedOptions.sync }, null, 2))
|
||||
if (groupedOptions.filter)
|
||||
setFilterOptionsJsonString(JSON.stringify({ ...filterOptions, ...groupedOptions.filter }, null, 2))
|
||||
if (groupedOptions.config)
|
||||
setConfigOptionsJsonString(JSON.stringify({ ...configOptions, ...groupedOptions.config }, null, 2))
|
||||
} else {
|
||||
if (groupedOptions.sync) setSyncOptionsJsonString(JSON.stringify(groupedOptions.sync, null, 2))
|
||||
if (groupedOptions.filter) setFilterOptionsJsonString(JSON.stringify(groupedOptions.filter, null, 2))
|
||||
if (groupedOptions.config) setConfigOptionsJsonString(JSON.stringify(groupedOptions.config, null, 2))
|
||||
}
|
||||
})
|
||||
}}
|
||||
getOptions={() => ({
|
||||
...syncOptions,
|
||||
...filterOptions,
|
||||
...configOptions,
|
||||
})}
|
||||
/>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{startSyncMutation.isSuccess ? (
|
||||
<motion.div
|
||||
key="started-buttons"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="flex flex-1 gap-2"
|
||||
>
|
||||
<Dropdown shadow={platform() === 'windows' ? 'none' : undefined}>
|
||||
<DropdownTrigger>
|
||||
<Button
|
||||
fullWidth={true}
|
||||
color="primary"
|
||||
size="lg"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
NEW SYNC
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu>
|
||||
<DropdownItem
|
||||
key="reset-paths"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setSource(undefined)
|
||||
setDest(undefined)
|
||||
setJsonError(null)
|
||||
startSyncMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset Paths
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="reset-options"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setSyncOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.copy,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setFilterOptionsJsonString('{}')
|
||||
setConfigOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.config,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setRemoteOptionsJsonString('{}')
|
||||
setCronExpression(null)
|
||||
setJsonError(null)
|
||||
startSyncMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset Options
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key="reset-all"
|
||||
onPress={() => {
|
||||
startTransition(() => {
|
||||
setSyncOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.copy,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setFilterOptionsJsonString('{}')
|
||||
setConfigOptionsJsonString(
|
||||
JSON.stringify(
|
||||
RCLONE_CONFIG_DEFAULTS.config,
|
||||
null,
|
||||
2
|
||||
)
|
||||
)
|
||||
setRemoteOptionsJsonString('{}')
|
||||
setSyncOptionsLocked(false)
|
||||
setFilterOptionsLocked(false)
|
||||
setConfigOptionsLocked(false)
|
||||
setRemoteOptionsLocked(false)
|
||||
setCronExpression(null)
|
||||
setJsonError(null)
|
||||
setDest(undefined)
|
||||
setSource(undefined)
|
||||
startSyncMutation.reset()
|
||||
})
|
||||
}}
|
||||
>
|
||||
Reset All
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
color="secondary"
|
||||
fullWidth={true}
|
||||
onPress={async () => {
|
||||
await openWindow({
|
||||
name: 'Transfers',
|
||||
url: '/transfers',
|
||||
})
|
||||
}}
|
||||
data-focus-visible="false"
|
||||
>
|
||||
VIEW TRANSFERS
|
||||
</Button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="start-button"
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2, ease: 'easeOut' }}
|
||||
className="flex flex-1"
|
||||
>
|
||||
<Button
|
||||
onPress={() => setTimeout(() => startSyncMutation.mutate(), 100)}
|
||||
size="lg"
|
||||
fullWidth={true}
|
||||
type="button"
|
||||
color="primary"
|
||||
isDisabled={
|
||||
startSyncMutation.isPending ||
|
||||
!!jsonError ||
|
||||
!source ||
|
||||
!dest ||
|
||||
source === dest
|
||||
}
|
||||
isLoading={startSyncMutation.isPending}
|
||||
endContent={buttonIcon}
|
||||
className="max-w-2xl gap-2"
|
||||
data-focus-visible="false"
|
||||
>
|
||||
{buttonText}
|
||||
</Button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<ButtonGroup variant="flat">
|
||||
<Tooltip
|
||||
content="Preview (Dry Run)"
|
||||
placement="top"
|
||||
size="lg"
|
||||
color="foreground"
|
||||
>
|
||||
<Button
|
||||
size="lg"
|
||||
type="button"
|
||||
color="primary"
|
||||
isIconOnly={true}
|
||||
isLoading={dryRunMutation.isPending}
|
||||
onPress={() => {
|
||||
if (
|
||||
dryRunMutation.isPending ||
|
||||
!!jsonError ||
|
||||
!source ||
|
||||
!dest ||
|
||||
source === dest
|
||||
) {
|
||||
return
|
||||
}
|
||||
setTimeout(() => dryRunMutation.mutate(), 100)
|
||||
}}
|
||||
>
|
||||
<EyeIcon className="size-6" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip content="Schedule task" placement="top" size="lg" color="foreground">
|
||||
<Button
|
||||
size="lg"
|
||||
type="button"
|
||||
color="primary"
|
||||
isIconOnly={true}
|
||||
onPress={() => {
|
||||
setTimeout(() => scheduleTaskMutation.mutate(), 100)
|
||||
}}
|
||||
>
|
||||
<ClockIcon className="size-6" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<CommandInfoButton
|
||||
content={`Sync the source to the destination, changing the destination only. Doesn't transfer files that are identical on source and destination, testing by size and modification time or MD5SUM. Destination is updated to match source, including deleting files if necessary (except duplicate objects, see below). If you don't want to delete files from destination, use the COPY command instead.
|
||||
const HELP_CONTENT = `Sync the source to the destination, changing the destination only. Doesn't transfer files that are identical on source and destination, testing by size and modification time or MD5SUM. Destination is updated to match source, including deleting files if necessary (except duplicate objects, see below). If you don't want to delete files from destination, use the COPY command instead.
|
||||
|
||||
Files in the destination won't be deleted if there were any errors at any point. Duplicate objects (files with the same name, on those providers that support it) are not yet handled.
|
||||
|
||||
@@ -659,10 +79,283 @@ Expand the accordion sections to customize your sync operation. Tap any chip on
|
||||
Tap the folder icon in the bottom bar to load or save option presets. Templates let you quickly apply common configurations without manually setting each option.
|
||||
|
||||
4. START THE SYNC
|
||||
Once paths are selected, tap "START SYNC" to begin. You can monitor progress on the Transfers page.`}
|
||||
Once paths are selected, tap "START SYNC" to begin. You can monitor progress on the Transfers page.`
|
||||
|
||||
export default function Sync() {
|
||||
const [searchParams] = useSearchParams()
|
||||
const { globalFlags, filterFlags, configFlags, syncFlags } = useFlags()
|
||||
|
||||
const [source, setSource] = useState<string | undefined>(
|
||||
searchParams.get('initialSource') ? searchParams.get('initialSource')! : undefined
|
||||
)
|
||||
const [dest, setDest] = useState<string | undefined>(
|
||||
searchParams.get('initialDestination') ? searchParams.get('initialDestination')! : undefined
|
||||
)
|
||||
|
||||
const {
|
||||
jsonError,
|
||||
setJsonError,
|
||||
groups: optionGroups,
|
||||
remotes: remotesGroup,
|
||||
applyTemplate,
|
||||
getMergedOptions,
|
||||
resetJson,
|
||||
resetLocks,
|
||||
} = useOptionGroups({
|
||||
groups: [
|
||||
{ key: 'sync', defaults: RCLONE_CONFIG_DEFAULTS.copy },
|
||||
{ key: 'filter' },
|
||||
{ key: 'config', defaults: RCLONE_CONFIG_DEFAULTS.config },
|
||||
],
|
||||
withRemotes: true,
|
||||
})
|
||||
const syncGroup = optionGroups.sync
|
||||
const filterGroup = optionGroups.filter
|
||||
const configGroup = optionGroups.config
|
||||
|
||||
const [cronExpression, setCronExpression] = useState<string | null>(null)
|
||||
|
||||
const selectedRemotes = useMemo(() => [source, dest].filter(Boolean), [source, dest])
|
||||
|
||||
const buildArgs = () => ({
|
||||
source: source!,
|
||||
destination: dest!,
|
||||
options: {
|
||||
config: configGroup.options,
|
||||
sync: syncGroup.options,
|
||||
filter: filterGroup.options,
|
||||
remotes: remotesGroup.options,
|
||||
},
|
||||
})
|
||||
|
||||
const startSyncMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!source || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
|
||||
return startSync(buildArgs())
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (cronExpression) {
|
||||
scheduleTaskMutation.mutate()
|
||||
}
|
||||
},
|
||||
onError: onErrorDialog('Sync', 'Failed to start sync', { log: ['Error starting sync:'] }),
|
||||
})
|
||||
|
||||
const scheduleTaskMutation = useScheduleTask({
|
||||
operation: 'sync',
|
||||
cronExpression,
|
||||
validate: () => {
|
||||
if (!source || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
},
|
||||
buildArgs,
|
||||
})
|
||||
|
||||
const dryRunMutation = useOperationDryRun(async () => {
|
||||
if (!source || !dest) {
|
||||
throw new Error('Please select both a source and destination path')
|
||||
}
|
||||
return startDryRun(() =>
|
||||
startSync({
|
||||
source,
|
||||
destination: dest,
|
||||
options: {
|
||||
config: { ...configGroup.options, dry_run: true },
|
||||
sync: syncGroup.options,
|
||||
filter: filterGroup.options,
|
||||
remotes: remotesGroup.options,
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
if (startSyncMutation.isPending) return 'STARTING...'
|
||||
if (!source) return 'Please select a source path'
|
||||
if (!dest) return 'Please select a destination path'
|
||||
if (source === dest) return 'Source and destination cannot be the same'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
if (cronExpression) return 'START AND SCHEDULE SYNC'
|
||||
return 'START SYNC'
|
||||
}, [startSyncMutation.isPending, source, dest, jsonError, cronExpression])
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (startSyncMutation.isPending) return
|
||||
if (!source || !dest || source === dest) return <FoldersIcon className="w-5 h-5" />
|
||||
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
|
||||
return <PlayIcon className="w-5 h-5 fill-current" />
|
||||
}, [startSyncMutation.isPending, source, dest, jsonError])
|
||||
|
||||
const accordionItems = useMemo<OptionsAccordionItemDef[]>(
|
||||
() => [
|
||||
{
|
||||
key: 'sync',
|
||||
category: 'sync',
|
||||
subtitle: getOptionsSubtitle(Object.keys(syncGroup.options).length),
|
||||
children: (
|
||||
<OptionsSection
|
||||
optionsJson={syncGroup.jsonString}
|
||||
setOptionsJson={syncGroup.setJsonString}
|
||||
globalOptions={globalFlags?.main || {}}
|
||||
availableOptions={syncFlags || []}
|
||||
isLocked={syncGroup.locked}
|
||||
setIsLocked={syncGroup.setLocked}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'filters',
|
||||
category: 'filters',
|
||||
subtitle: getOptionsSubtitle(Object.keys(filterGroup.options).length),
|
||||
children: (
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.filter || {}}
|
||||
optionsJson={filterGroup.jsonString}
|
||||
setOptionsJson={filterGroup.setJsonString}
|
||||
availableOptions={filterFlags || []}
|
||||
isLocked={filterGroup.locked}
|
||||
setIsLocked={filterGroup.setLocked}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'cron',
|
||||
category: 'cron',
|
||||
children: <CronEditor expression={cronExpression} onChange={setCronExpression} />,
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
category: 'config',
|
||||
subtitle: getOptionsSubtitle(Object.keys(configGroup.options).length),
|
||||
children: (
|
||||
<OptionsSection
|
||||
globalOptions={globalFlags?.main || {}}
|
||||
optionsJson={configGroup.jsonString}
|
||||
setOptionsJson={configGroup.setJsonString}
|
||||
availableOptions={configFlags || []}
|
||||
isLocked={configGroup.locked}
|
||||
setIsLocked={configGroup.setLocked}
|
||||
/>
|
||||
),
|
||||
},
|
||||
...(selectedRemotes.length > 0
|
||||
? [
|
||||
{
|
||||
key: 'remotes',
|
||||
category: 'remotes' as const,
|
||||
subtitle: getOptionsSubtitle(
|
||||
Object.values(remotesGroup.options).reduce(
|
||||
(acc, opts) => acc + Object.keys(opts).length,
|
||||
0
|
||||
)
|
||||
),
|
||||
children: (
|
||||
<RemoteOptionsSection
|
||||
selectedRemotes={selectedRemotes}
|
||||
remoteOptionsJson={remotesGroup.json}
|
||||
setRemoteOptionsJson={remotesGroup.setJson}
|
||||
reconcileRemotes={remotesGroup.reconcile}
|
||||
setRemoteOptionsLocked={remotesGroup.setLocked}
|
||||
remoteOptionsLocked={remotesGroup.locked}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[
|
||||
syncGroup,
|
||||
filterGroup,
|
||||
configGroup,
|
||||
globalFlags,
|
||||
syncFlags,
|
||||
filterFlags,
|
||||
configFlags,
|
||||
cronExpression,
|
||||
selectedRemotes,
|
||||
remotesGroup,
|
||||
]
|
||||
)
|
||||
|
||||
const handleStart = useCallback(() => startSyncMutation.mutate(), [startSyncMutation.mutate])
|
||||
|
||||
const handleSchedule = useCallback(
|
||||
() => scheduleTaskMutation.mutate(),
|
||||
[scheduleTaskMutation.mutate]
|
||||
)
|
||||
|
||||
const handleDryRun = useCallback(() => dryRunMutation.mutate(), [dryRunMutation.mutate])
|
||||
|
||||
const handleResetPaths = useCallback(() => {
|
||||
startTransition(() => {
|
||||
setSource(undefined)
|
||||
setDest(undefined)
|
||||
setJsonError(null)
|
||||
startSyncMutation.reset()
|
||||
})
|
||||
}, [setJsonError, startSyncMutation.reset])
|
||||
|
||||
const handleResetOptions = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
setCronExpression(null)
|
||||
startSyncMutation.reset()
|
||||
})
|
||||
}, [resetJson, startSyncMutation.reset])
|
||||
|
||||
const handleResetAll = useCallback(() => {
|
||||
startTransition(() => {
|
||||
resetJson()
|
||||
resetLocks()
|
||||
setCronExpression(null)
|
||||
setDest(undefined)
|
||||
setSource(undefined)
|
||||
startSyncMutation.reset()
|
||||
})
|
||||
}, [resetJson, resetLocks, startSyncMutation.reset])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10">
|
||||
{/* Main Content */}
|
||||
<OperationWindowContent>
|
||||
{/* Paths Display */}
|
||||
<PathFinder
|
||||
sourcePath={source}
|
||||
setSourcePath={setSource}
|
||||
destPath={dest}
|
||||
setDestPath={setDest}
|
||||
sourceOptions={SOURCE_OPTIONS}
|
||||
destOptions={DEST_OPTIONS}
|
||||
/>
|
||||
|
||||
<OptionsAccordion banner={true} items={accordionItems} />
|
||||
</OperationWindowContent>
|
||||
|
||||
<OperationWindowFooter>
|
||||
<OperationFooter
|
||||
operation="sync"
|
||||
templatesDisabled={!!jsonError}
|
||||
onTemplateSelect={applyTemplate}
|
||||
getTemplateOptions={getMergedOptions}
|
||||
startIsSuccess={startSyncMutation.isSuccess}
|
||||
startIsPending={startSyncMutation.isPending}
|
||||
onStart={handleStart}
|
||||
onSchedule={handleSchedule}
|
||||
dryRunIsPending={dryRunMutation.isPending}
|
||||
onDryRun={handleDryRun}
|
||||
startBlocked={!!jsonError || !source || !dest || source === dest}
|
||||
buttonText={buttonText}
|
||||
buttonIcon={buttonIcon}
|
||||
newLabel="NEW SYNC"
|
||||
onResetPaths={handleResetPaths}
|
||||
onResetOptions={handleResetOptions}
|
||||
onResetAll={handleResetAll}
|
||||
helpContent={HELP_CONTENT}
|
||||
/>
|
||||
<CommandsDropdown currentCommand="sync" />
|
||||
</ButtonGroup>
|
||||
</OperationWindowFooter>
|
||||
</div>
|
||||
)
|
||||
|
||||
+42
-39
@@ -1,8 +1,9 @@
|
||||
import { LazyStore } from '@tauri-apps/plugin-store'
|
||||
import { create } from 'zustand'
|
||||
import { type StateStorage, createJSONStorage, persist } from 'zustand/middleware'
|
||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||
import type { ConfigFile } from '../types/config'
|
||||
import type { ScheduledTask } from '../types/schedules'
|
||||
import { createTauriStateStorage, waitForStoreHydration } from './lib'
|
||||
|
||||
let activeHostId: string | null = null
|
||||
let activeStore: LazyStore | null = null
|
||||
@@ -10,15 +11,8 @@ let disposeKeyChange: (() => void) | null = null
|
||||
|
||||
export async function initHostStore(hostId: string) {
|
||||
if (activeHostId === hostId && activeStore) {
|
||||
async function waitForHostStoreHydration() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
if (!useHostStore.persist.hasHydrated()) {
|
||||
await waitForHostStoreHydration()
|
||||
}
|
||||
await waitForStoreHydration(() => useHostStore.persist.hasHydrated())
|
||||
console.log('[waitForHostStoreHydration] host store hydrated')
|
||||
}
|
||||
|
||||
await waitForHostStoreHydration()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -45,25 +39,6 @@ export async function initHostStore(hostId: string) {
|
||||
await useHostStore.persist.rehydrate()
|
||||
}
|
||||
|
||||
const getStorage = (): StateStorage => ({
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
if (!activeStore) return null
|
||||
// console.log('[HostStore] getItem', { name, host: activeHostId })
|
||||
return (await activeStore.get(name)) ?? null
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
if (!activeStore) return
|
||||
console.log('[HostStore] setItem', { name, value })
|
||||
await activeStore.set(name, value)
|
||||
await activeStore.save()
|
||||
},
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
if (!activeStore) return
|
||||
await activeStore.delete(name)
|
||||
await activeStore.save()
|
||||
},
|
||||
})
|
||||
|
||||
export interface RemoteConfig {
|
||||
mountOnStart?: {
|
||||
enabled: boolean
|
||||
@@ -103,11 +78,16 @@ interface HostState {
|
||||
configFiles: ConfigFile[]
|
||||
addConfigFile: (configFile: ConfigFile) => void
|
||||
removeConfigFile: (id: string) => void
|
||||
activeConfigFile: ConfigFile | null
|
||||
setActiveConfigFile: (configFile: string) => void
|
||||
activeConfigId: string | null
|
||||
setActiveConfigFile: (id: string) => void
|
||||
updateConfigFile: (id: string, configFile: Partial<ConfigFile>) => void
|
||||
|
||||
lastSkippedVersion: string | undefined
|
||||
|
||||
// Resolved-once location of the "default" rclone config for this host. Pinned so switching
|
||||
// the rclone binary never relocates where the user's remotes are read from.
|
||||
defaultConfigPath: string | undefined
|
||||
setDefaultConfigPath: (path: string | undefined) => void
|
||||
}
|
||||
|
||||
export const useHostStore = create<HostState>()(
|
||||
@@ -138,7 +118,7 @@ export const useHostStore = create<HostState>()(
|
||||
>
|
||||
) => {
|
||||
const state = get()
|
||||
const configId = state.activeConfigFile?.id
|
||||
const configId = state.activeConfigId
|
||||
|
||||
if (!configId) {
|
||||
console.error('No active config file for scheduled task')
|
||||
@@ -178,29 +158,52 @@ export const useHostStore = create<HostState>()(
|
||||
set((state) => ({
|
||||
configFiles: state.configFiles.filter((f) => f.id !== id),
|
||||
})),
|
||||
activeConfigFile: null,
|
||||
activeConfigId: null,
|
||||
setActiveConfigFile: (id: string) =>
|
||||
set((state) => ({
|
||||
activeConfigFile: state.configFiles.find((f) => f.id === id) || null,
|
||||
activeConfigId: state.configFiles.some((f) => f.id === id) ? id : null,
|
||||
})),
|
||||
updateConfigFile: (id: string, configFile: Partial<ConfigFile>) =>
|
||||
set((state) => ({
|
||||
configFiles: state.configFiles.map((f) =>
|
||||
f.id === id ? { ...f, ...configFile } : f
|
||||
),
|
||||
activeConfigFile:
|
||||
state.activeConfigFile?.id === id
|
||||
? { ...state.activeConfigFile, ...configFile }
|
||||
: state.activeConfigFile,
|
||||
})),
|
||||
|
||||
lastSkippedVersion: undefined,
|
||||
|
||||
defaultConfigPath: undefined,
|
||||
setDefaultConfigPath: (path: string | undefined) =>
|
||||
set((_) => ({ defaultConfigPath: path })),
|
||||
}),
|
||||
{
|
||||
name: 'host-store',
|
||||
storage: createJSONStorage(getStorage),
|
||||
storage: createJSONStorage(() => createTauriStateStorage(() => activeStore)),
|
||||
skipHydration: true,
|
||||
version: 1,
|
||||
version: 2,
|
||||
migrate: (persistedState, version) => {
|
||||
// v1 stored the full active ConfigFile object; v2 stores just its id. Also handles
|
||||
// the version-1 blob written by the persisted-store's legacy migration, whose
|
||||
// configFiles can be undefined.
|
||||
if (version < 2 && persistedState) {
|
||||
const { activeConfigFile, configFiles, ...rest } = persistedState as {
|
||||
activeConfigFile?: ConfigFile | null
|
||||
configFiles?: ConfigFile[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
return {
|
||||
...rest,
|
||||
configFiles: configFiles ?? [],
|
||||
activeConfigId: activeConfigFile?.id ?? null,
|
||||
}
|
||||
}
|
||||
return persistedState
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
/** Resolves the active ConfigFile object from the stored id, or null if it no longer exists. */
|
||||
export function selectActiveConfigFile(state: HostState): ConfigFile | null {
|
||||
return state.configFiles.find((f) => f.id === state.activeConfigId) ?? null
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@ import { shared } from 'use-broadcast-ts'
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface State {
|
||||
firstWindow: boolean
|
||||
|
||||
startupStatus:
|
||||
| null
|
||||
| 'initializing'
|
||||
@@ -17,11 +15,6 @@ interface State {
|
||||
|
||||
isRestartingRclone: boolean
|
||||
|
||||
currentTheme: {
|
||||
app: 'light' | 'dark' | 'system'
|
||||
tray: 'light' | 'dark' | 'system'
|
||||
}
|
||||
|
||||
cloudflaredTunnel: {
|
||||
pid: number
|
||||
url: string
|
||||
@@ -33,18 +26,11 @@ interface State {
|
||||
export const useStore = create<State>()(
|
||||
shared(
|
||||
(_) => ({
|
||||
firstWindow: true,
|
||||
|
||||
startupStatus: null,
|
||||
startupDisplayed: false,
|
||||
|
||||
isRestartingRclone: false,
|
||||
|
||||
currentTheme: {
|
||||
app: 'dark',
|
||||
tray: 'system',
|
||||
},
|
||||
|
||||
cloudflaredTunnel: null,
|
||||
|
||||
dryRunJobIds: [],
|
||||
|
||||
+47
-76
@@ -4,13 +4,14 @@ import { platform } from '@tauri-apps/plugin-os'
|
||||
import { exit } from '@tauri-apps/plugin-process'
|
||||
import { LazyStore } from '@tauri-apps/plugin-store'
|
||||
import { create } from 'zustand'
|
||||
import { type StateStorage, createJSONStorage, persist } from 'zustand/middleware'
|
||||
import { createJSONStorage, persist } from 'zustand/middleware'
|
||||
import type { Host } from '../lib/hosts'
|
||||
import type { SERVE_TYPES } from '../lib/rclone/constants'
|
||||
import type { ConfigFile } from '../types/config'
|
||||
import type { ScheduledTask } from '../types/schedules'
|
||||
import type { Template } from '../types/template'
|
||||
import type { RemoteConfig as HostRemoteConfig } from './host'
|
||||
import { createTauriStateStorage } from './lib'
|
||||
|
||||
const store = new LazyStore('store.json')
|
||||
|
||||
@@ -33,17 +34,6 @@ interface RemoteConfigV1 {
|
||||
remoteDefaults?: Record<string, any>
|
||||
}
|
||||
|
||||
type SupportedAction =
|
||||
| 'tray-mount'
|
||||
| 'tray-sync'
|
||||
| 'tray-copy'
|
||||
| 'tray-serve'
|
||||
| 'tray-move'
|
||||
| 'tray-bisync'
|
||||
| 'tray-delete'
|
||||
| 'tray-purge'
|
||||
| 'tray-download'
|
||||
|
||||
interface TemplateV1 {
|
||||
id: string
|
||||
name: string
|
||||
@@ -53,12 +43,6 @@ interface TemplateV1 {
|
||||
|
||||
interface PersistedStateV1 {
|
||||
remoteConfigList: Record<string, RemoteConfigV1>
|
||||
setRemoteConfig: (remote: string, config: RemoteConfigV1) => void
|
||||
mergeRemoteConfig: (remote: string, config: RemoteConfigV1) => void
|
||||
|
||||
disabledActions: SupportedAction[]
|
||||
|
||||
setDisabledActions: (actions: SupportedAction[]) => void
|
||||
|
||||
proxy:
|
||||
| {
|
||||
@@ -70,34 +54,18 @@ interface PersistedStateV1 {
|
||||
favoritePaths: { remote: string; path: string; added: number }[]
|
||||
|
||||
settingsPass: string | undefined
|
||||
setSettingsPass: (pass: string | undefined) => void
|
||||
|
||||
licenseKey: string | undefined
|
||||
setLicenseKey: (key: string | undefined) => void
|
||||
licenseValid: boolean
|
||||
setLicenseValid: (valid: boolean) => void
|
||||
|
||||
startOnBoot: boolean
|
||||
setStartOnBoot: (startOnBoot: boolean) => void
|
||||
|
||||
scheduledTasks: ScheduledTask[]
|
||||
addScheduledTask: (
|
||||
task: Omit<
|
||||
ScheduledTask,
|
||||
'id' | 'isRunning' | 'currentRunId' | 'lastRun' | 'configId' | 'isEnabled'
|
||||
>
|
||||
) => void
|
||||
removeScheduledTask: (id: string) => void
|
||||
updateScheduledTask: (id: string, task: Partial<ScheduledTask>) => void
|
||||
|
||||
templates: TemplateV1[]
|
||||
|
||||
configFiles: ConfigFile[]
|
||||
addConfigFile: (configFile: ConfigFile) => void
|
||||
removeConfigFile: (id: string) => void
|
||||
activeConfigFile: ConfigFile | null
|
||||
setActiveConfigFile: (configFile: string) => void
|
||||
updateConfigFile: (id: string, configFile: Partial<ConfigFile>) => void
|
||||
|
||||
lastSkippedVersion: string | undefined
|
||||
|
||||
@@ -126,8 +94,7 @@ interface PersistedStateV2 {
|
||||
templates: Template[]
|
||||
|
||||
hosts: Host[]
|
||||
currentHost: Host | null
|
||||
updateHost: (id: Host['id'], host: Partial<Host>) => void
|
||||
currentHostId: string | null
|
||||
setCurrentHost: (id: Host['id']) => void
|
||||
|
||||
hideStartup: boolean
|
||||
@@ -138,24 +105,19 @@ interface PersistedStateV2 {
|
||||
tray: 'light' | 'dark' | 'system' | 'color'
|
||||
app: 'light' | 'dark' | 'system'
|
||||
}
|
||||
}
|
||||
|
||||
const getStorage = (store: LazyStore): StateStorage => ({
|
||||
getItem: async (name: string): Promise<string | null> => {
|
||||
console.log('getItem', { name })
|
||||
return (await store.get(name)) ?? null
|
||||
},
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
console.log('setItem', { name, value })
|
||||
await store.set(name, value)
|
||||
await store.save()
|
||||
},
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
console.log('removeItem', { name })
|
||||
await store.delete(name)
|
||||
await store.save()
|
||||
},
|
||||
})
|
||||
// Absolute path of the rclone executable the app runs. Managed downloads live under
|
||||
// $APPLOCALDATA/rclone-versions/vX/, a system rclone is its PATH location, and a custom
|
||||
// binary is any other path. `undefined` triggers one-time adoption at startup.
|
||||
rclonePath: string | undefined
|
||||
setRclonePath: (path: string | undefined) => void
|
||||
|
||||
// Download + switch to new stable rclone releases at startup (managed binaries only).
|
||||
// When off, the app still checks and notifies once per new version.
|
||||
autoUpdateRclone: boolean
|
||||
setAutoUpdateRclone: (enabled: boolean) => void
|
||||
lastNotifiedRcloneVersion: string | undefined
|
||||
}
|
||||
|
||||
export const usePersistedStore = create<PersistedStateV2>()(
|
||||
persist(
|
||||
@@ -180,32 +142,14 @@ export const usePersistedStore = create<PersistedStateV2>()(
|
||||
templates: [],
|
||||
|
||||
hosts: [],
|
||||
currentHost: null,
|
||||
updateHost: (id: Host['id'], host: Partial<Host>) =>
|
||||
currentHostId: null,
|
||||
setCurrentHost: (id: Host['id']) =>
|
||||
set((state) => {
|
||||
if (!state.hosts.some((h) => h.id === id)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const hosts = state.hosts.map((h) =>
|
||||
h.id === id ? { ...h, ...host, id: h.id } : h
|
||||
)
|
||||
|
||||
const currentHost =
|
||||
state.currentHost?.id === id
|
||||
? (hosts.find((h) => h.id === id) ?? state.currentHost)
|
||||
: state.currentHost
|
||||
|
||||
return { hosts, currentHost }
|
||||
}),
|
||||
setCurrentHost: (id: Host['id']) =>
|
||||
set((state) => {
|
||||
const host = state.hosts.find((h) => h.id === id)
|
||||
if (!host) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return { currentHost: host }
|
||||
return { currentHostId: id }
|
||||
}),
|
||||
|
||||
hideStartup: false,
|
||||
@@ -216,11 +160,18 @@ export const usePersistedStore = create<PersistedStateV2>()(
|
||||
tray: platform() === 'linux' ? 'color' : 'system',
|
||||
app: 'dark',
|
||||
},
|
||||
|
||||
rclonePath: undefined,
|
||||
setRclonePath: (path: string | undefined) => set((_) => ({ rclonePath: path })),
|
||||
|
||||
autoUpdateRclone: true,
|
||||
setAutoUpdateRclone: (enabled: boolean) => set((_) => ({ autoUpdateRclone: enabled })),
|
||||
lastNotifiedRcloneVersion: undefined,
|
||||
}),
|
||||
{
|
||||
name: 'store',
|
||||
storage: createJSONStorage(() => getStorage(store)),
|
||||
version: 2,
|
||||
storage: createJSONStorage(() => createTauriStateStorage(() => store)),
|
||||
version: 3,
|
||||
migrate: async (persistedState, version) => {
|
||||
if (!persistedState) {
|
||||
return persistedState as PersistedStateV2
|
||||
@@ -385,12 +336,32 @@ export const usePersistedStore = create<PersistedStateV2>()(
|
||||
} as unknown as PersistedStateV2
|
||||
}
|
||||
|
||||
if (version < 3) {
|
||||
// v2 stored the full current Host object; v3 stores just its id.
|
||||
const { currentHost, ...rest } = persistedState as PersistedStateV2 & {
|
||||
currentHost?: Host | null
|
||||
}
|
||||
return {
|
||||
...rest,
|
||||
currentHostId: currentHost?.id ?? null,
|
||||
} as PersistedStateV2
|
||||
}
|
||||
|
||||
return persistedState as PersistedStateV2
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
/** Resolves the current Host object from the stored id, or null if it no longer exists. */
|
||||
export function selectCurrentHost(state: PersistedStateV2): Host | null {
|
||||
return state.hosts.find((h) => h.id === state.currentHostId) ?? null
|
||||
}
|
||||
|
||||
export function useCurrentHost(): Host | null {
|
||||
return usePersistedStore(selectCurrentHost)
|
||||
}
|
||||
|
||||
usePersistedStore.persist.onFinishHydration((state) => {
|
||||
if (state.toolbarShortcut) {
|
||||
invoke('update_toolbar_shortcut', { shortcut: state.toolbarShortcut })
|
||||
|
||||
+44
-61
@@ -1,15 +1,16 @@
|
||||
import { captureException } from '@sentry/browser'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
|
||||
import { ask, message } from '@tauri-apps/plugin-dialog'
|
||||
import { openUrl, revealItemInDir } from '@tauri-apps/plugin-opener'
|
||||
import { reportError } from '../lib/errors'
|
||||
import { CLOSE_APP, emitToMain } from '../lib/events'
|
||||
import notify from '../lib/notify'
|
||||
import queryClient from '../lib/query'
|
||||
import type { fetchMountList, fetchServeList } from '../lib/rclone/api'
|
||||
import rclone from '../lib/rclone/client'
|
||||
import { SERVE_TYPES, SUPPORTS_CLEANUP, SUPPORTS_PURGE } from '../lib/rclone/constants'
|
||||
import { openFullWindow } from '../lib/window'
|
||||
import { usePersistedStore } from '../store/persisted'
|
||||
import { selectCurrentHost, usePersistedStore } from '../store/persisted'
|
||||
import { COMMAND_CONFIG, COMMAND_DESCRIPTIONS, COMMAND_KEYWORDS } from './constants'
|
||||
import type {
|
||||
ToolbarActionArgs,
|
||||
@@ -272,7 +273,7 @@ const actions: ToolbarActionDefinition[] = [
|
||||
for (const mount of activeMounts) {
|
||||
const mountLabel = formatMountLabel(mount)
|
||||
|
||||
if (usePersistedStore.getState().currentHost?.id === 'local') {
|
||||
if (usePersistedStore.getState().currentHostId === 'local') {
|
||||
results.push(
|
||||
createBaseResult(
|
||||
`Open ${mountLabel}`,
|
||||
@@ -343,14 +344,12 @@ const actions: ToolbarActionDefinition[] = [
|
||||
try {
|
||||
await revealItemInDir(mountPoint)
|
||||
} catch (error) {
|
||||
console.error('[toolbar] failed to open mount', error)
|
||||
await message(
|
||||
error instanceof Error ? error.message : 'Failed to open mount point',
|
||||
{
|
||||
await reportError(error, {
|
||||
title: 'Open Mount',
|
||||
kind: 'error',
|
||||
}
|
||||
)
|
||||
fallback: 'Failed to open mount point',
|
||||
capture: false,
|
||||
log: ['[toolbar] failed to open mount'],
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -395,14 +394,12 @@ const actions: ToolbarActionDefinition[] = [
|
||||
old?.filter((m) => m.MountPoint !== mountPoint) ?? []
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[toolbar] failed to stop mount', error)
|
||||
await message(
|
||||
error instanceof Error ? error.message : 'Failed to stop mount instance',
|
||||
{
|
||||
await reportError(error, {
|
||||
title: 'Stop Mount',
|
||||
kind: 'error',
|
||||
}
|
||||
)
|
||||
fallback: 'Failed to stop mount instance',
|
||||
capture: false,
|
||||
log: ['[toolbar] failed to stop mount'],
|
||||
})
|
||||
await queryClient.resetQueries({ queryKey: ['mount', 'list'] })
|
||||
}
|
||||
return
|
||||
@@ -426,16 +423,12 @@ const actions: ToolbarActionDefinition[] = [
|
||||
})
|
||||
queryClient.setQueryData(['mount', 'list'], [])
|
||||
} catch (error) {
|
||||
console.error('[toolbar] failed to stop all mounts', error)
|
||||
await message(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to stop all mount instances',
|
||||
{
|
||||
await reportError(error, {
|
||||
title: 'Stop All Mounts',
|
||||
kind: 'error',
|
||||
}
|
||||
)
|
||||
fallback: 'Failed to stop all mount instances',
|
||||
capture: false,
|
||||
log: ['[toolbar] failed to stop all mounts'],
|
||||
})
|
||||
await queryClient.resetQueries({ queryKey: ['mount', 'list'] })
|
||||
}
|
||||
return
|
||||
@@ -570,14 +563,12 @@ const actions: ToolbarActionDefinition[] = [
|
||||
old?.filter((s) => s.id !== serveId) ?? []
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[toolbar] failed to stop serve', error)
|
||||
await message(
|
||||
error instanceof Error ? error.message : 'Failed to stop serve instance',
|
||||
{
|
||||
await reportError(error, {
|
||||
title: 'Stop Serve',
|
||||
kind: 'error',
|
||||
}
|
||||
)
|
||||
fallback: 'Failed to stop serve instance',
|
||||
capture: false,
|
||||
log: ['[toolbar] failed to stop serve'],
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -591,16 +582,12 @@ const actions: ToolbarActionDefinition[] = [
|
||||
})
|
||||
queryClient.setQueryData(['serve', 'list'], [])
|
||||
} catch (error) {
|
||||
console.error('[toolbar] failed to stop all serves', error)
|
||||
await message(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Failed to stop all serve instances',
|
||||
{
|
||||
await reportError(error, {
|
||||
title: 'Stop All Serves',
|
||||
kind: 'error',
|
||||
}
|
||||
)
|
||||
fallback: 'Failed to stop all serve instances',
|
||||
capture: false,
|
||||
log: ['[toolbar] failed to stop all serves'],
|
||||
})
|
||||
await queryClient.resetQueries({ queryKey: ['serve', 'list'] })
|
||||
}
|
||||
return
|
||||
@@ -803,8 +790,8 @@ const actions: ToolbarActionDefinition[] = [
|
||||
return
|
||||
}
|
||||
|
||||
const persistedStoreState = usePersistedStore.getState()
|
||||
const hostUrl = persistedStoreState.currentHost?.url
|
||||
const currentHost = selectCurrentHost(usePersistedStore.getState())
|
||||
const hostUrl = currentHost?.url
|
||||
|
||||
if (!hostUrl) {
|
||||
await notify({
|
||||
@@ -816,10 +803,10 @@ const actions: ToolbarActionDefinition[] = [
|
||||
|
||||
try {
|
||||
let auth: string | undefined
|
||||
const authUser = persistedStoreState.currentHost?.authUser
|
||||
const authUser = currentHost?.authUser
|
||||
|
||||
if (authUser) {
|
||||
const authPassword = persistedStoreState.currentHost?.authPassword
|
||||
const authPassword = currentHost?.authPassword
|
||||
auth = btoa(`${authUser}:${authPassword ?? ''}`)
|
||||
}
|
||||
|
||||
@@ -1175,7 +1162,7 @@ const actions: ToolbarActionDefinition[] = [
|
||||
return []
|
||||
},
|
||||
onPress: async () => {
|
||||
await getCurrentWindow().emit('close-app')
|
||||
await emitToMain(CLOSE_APP)
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -1253,14 +1240,12 @@ const actions: ToolbarActionDefinition[] = [
|
||||
(old: string[] | undefined) => old?.filter((v) => v !== fs) ?? []
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[toolbar] failed to forget VFS cache', error)
|
||||
await message(
|
||||
error instanceof Error ? error.message : 'Failed to clear VFS cache',
|
||||
{
|
||||
await reportError(error, {
|
||||
title: 'VFS Forget',
|
||||
kind: 'error',
|
||||
}
|
||||
)
|
||||
fallback: 'Failed to clear VFS cache',
|
||||
capture: false,
|
||||
log: ['[toolbar] failed to forget VFS cache'],
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1274,14 +1259,12 @@ const actions: ToolbarActionDefinition[] = [
|
||||
})
|
||||
queryClient.setQueryData(['vfs', 'list'], [])
|
||||
} catch (error) {
|
||||
console.error('[toolbar] failed to forget all VFS caches', error)
|
||||
await message(
|
||||
error instanceof Error ? error.message : 'Failed to clear all VFS caches',
|
||||
{
|
||||
await reportError(error, {
|
||||
title: 'VFS Forget All',
|
||||
kind: 'error',
|
||||
}
|
||||
)
|
||||
fallback: 'Failed to clear all VFS caches',
|
||||
capture: false,
|
||||
log: ['[toolbar] failed to forget all VFS caches'],
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user