year of compiled react, bump to 1.9.2
Signed-off-by: FTCHD <144691102+FTCHD@users.noreply.github.com>
This commit is contained in:
@@ -132,9 +132,10 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
with:
|
||||
tagName: v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version.
|
||||
tagName: v__VERSION__
|
||||
releaseName: 'v__VERSION__'
|
||||
releaseBody: 'See the assets to download this version and install.'
|
||||
releaseBody: "<img width=\"1645\" height=\"626\" alt=\"Rclone UI\" src=\"https://github.com/user-attachments/assets/539c0b90-fee1-487e-879a-6a9168a44508\" />\n\n# Rclone UI v__VERSION__\n- Bug squashing & improvements.\n\n**See the assets to download this version and install.**"
|
||||
assetNamePattern: '[name]_[platform]_[arch][ext]'
|
||||
releaseDraft: true
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
@@ -777,3 +777,29 @@ export async function getConfigFlags() {
|
||||
|
||||
return copyFlags
|
||||
}
|
||||
|
||||
export async function getSftpFlags() {
|
||||
console.log('[getSftpFlags]')
|
||||
|
||||
const r = await fetch('http://localhost:5572/options/info', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeader(),
|
||||
}).then((res) => res.json() as Promise<any>)
|
||||
|
||||
const sftpFlags = r.sftp
|
||||
|
||||
return sftpFlags.sort((a: any, b: any) => a.Name.localeCompare(b.Name))
|
||||
}
|
||||
|
||||
export async function getFtpFlags() {
|
||||
console.log('[getFtpFlags]')
|
||||
|
||||
const r = await fetch('http://localhost:5572/options/info', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeader(),
|
||||
}).then((res) => res.json() as Promise<any>)
|
||||
|
||||
const ftpFlags = r.ftp
|
||||
|
||||
return ftpFlags.sort((a: any, b: any) => a.Name.localeCompare(b.Name))
|
||||
}
|
||||
|
||||
@@ -433,6 +433,7 @@ async function handleTask(task: ScheduledTask) {
|
||||
currentRunId: undefined,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
})
|
||||
} finally {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+23
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "s-tray",
|
||||
"version": "1.9.1",
|
||||
"version": "1.9.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "s-tray",
|
||||
"version": "1.9.1",
|
||||
"version": "1.9.2",
|
||||
"dependencies": {
|
||||
"@heroui/react": "^2.7.11",
|
||||
"@sentry/browser": "^10.8.0",
|
||||
@@ -32,6 +32,7 @@
|
||||
"lucide-react": "^0.536.0",
|
||||
"p-retry": "^6.2.1",
|
||||
"react": "^18.3.1",
|
||||
"react-compiler-runtime": "^19.1.0-rc.3",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.1",
|
||||
"tauri-plugin-sentry-api": "^0.4.1",
|
||||
@@ -47,6 +48,7 @@
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"babel-plugin-react-compiler": "^19.1.0-rc.3",
|
||||
"esbuild": "^0.25.8",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
@@ -5762,6 +5764,16 @@
|
||||
"postcss": "^8.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/babel-plugin-react-compiler": {
|
||||
"version": "19.1.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-19.1.0-rc.3.tgz",
|
||||
"integrity": "sha512-mjRn69WuTz4adL0bXGx8Rsyk1086zFJeKmes6aK0xPuK3aaXmDJdLHqwKKMrpm6KAI1MCoUK72d2VeqQbu8YIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -6928,6 +6940,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-compiler-runtime": {
|
||||
"version": "19.1.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/react-compiler-runtime/-/react-compiler-runtime-19.1.0-rc.3.tgz",
|
||||
"integrity": "sha512-Cssogys2XZu6SqxRdX2xd8cQAf57BBvFbLEBlIa77161lninbKUn/EqbecCe7W3eqDQfg3rIoOwzExzgCh7h/g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^0.0.0-experimental"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "s-tray",
|
||||
"private": true,
|
||||
"version": "1.9.1",
|
||||
"version": "1.9.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node scripts/buildExternal.js && vite",
|
||||
@@ -43,6 +43,7 @@
|
||||
"lucide-react": "^0.536.0",
|
||||
"p-retry": "^6.2.1",
|
||||
"react": "^18.3.1",
|
||||
"react-compiler-runtime": "^19.1.0-rc.3",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.1",
|
||||
"tauri-plugin-sentry-api": "^0.4.1",
|
||||
@@ -58,6 +59,7 @@
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"babel-plugin-react-compiler": "^19.1.0-rc.3",
|
||||
"esbuild": "^0.25.8",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
|
||||
Generated
+1
-1
@@ -103,7 +103,7 @@ checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
|
||||
|
||||
[[package]]
|
||||
name = "app"
|
||||
version = "1.9.1"
|
||||
version = "1.9.2"
|
||||
dependencies = [
|
||||
"cocoa",
|
||||
"fix-path-env",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "1.9.1"
|
||||
version = "1.9.2"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
|
||||
@@ -311,6 +311,9 @@
|
||||
},
|
||||
{
|
||||
"url": "https://checkout.link.com/**"
|
||||
},
|
||||
{
|
||||
"url": "https://**.sentry.io/**"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+112
-1
@@ -52,6 +52,117 @@ fn unzip_file(zip_path: &str, output_folder: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn stop_pid(pid: u32, timeout_ms: Option<u64>) -> Result<(), String> {
|
||||
let timeout = timeout_ms.unwrap_or(5000);
|
||||
|
||||
#[cfg(any(
|
||||
target_os = "macos",
|
||||
target_os = "linux",
|
||||
target_os = "freebsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "netbsd"
|
||||
))]
|
||||
{
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let pid_str = pid.to_string();
|
||||
|
||||
// Try graceful termination first
|
||||
let _ = std::process::Command::new("kill")
|
||||
.args(&["-TERM", &pid_str])
|
||||
.status();
|
||||
|
||||
let deadline = Instant::now() + Duration::from_millis(timeout);
|
||||
while Instant::now() < deadline {
|
||||
// Check if process still exists: kill -0 <pid>
|
||||
let alive = std::process::Command::new("kill")
|
||||
.args(&["-0", &pid_str])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if !alive {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
// Force kill
|
||||
let _ = std::process::Command::new("kill")
|
||||
.args(&["-KILL", &pid_str])
|
||||
.status();
|
||||
|
||||
// Final check (best effort)
|
||||
let alive = std::process::Command::new("kill")
|
||||
.args(&["-0", &pid_str])
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if alive {
|
||||
return Err("Failed to terminate process".to_string());
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
let pid_str = pid.to_string();
|
||||
|
||||
// Try graceful termination first
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(&["/PID", &pid_str])
|
||||
.status();
|
||||
|
||||
let deadline = Instant::now() + Duration::from_millis(timeout);
|
||||
while Instant::now() < deadline {
|
||||
let output = std::process::Command::new("tasklist")
|
||||
.args(&["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
if stdout.trim().is_empty()
|
||||
|| stdout.contains("No tasks are running")
|
||||
|| !stdout.contains(&pid_str)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
// Force kill
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(&["/PID", &pid_str, "/F", "/T"])
|
||||
.status();
|
||||
|
||||
// Final check
|
||||
let output = std::process::Command::new("tasklist")
|
||||
.args(&["/FI", &format!("PID eq {}", pid), "/FO", "CSV", "/NH"])
|
||||
.output()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
if !stdout.trim().is_empty() && stdout.contains(&pid_str) && !stdout.contains("No tasks are running") {
|
||||
return Err("Failed to terminate process".to_string());
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
#[cfg(not(any(
|
||||
target_os = "macos",
|
||||
target_os = "linux",
|
||||
target_os = "freebsd",
|
||||
target_os = "openbsd",
|
||||
target_os = "netbsd",
|
||||
target_os = "windows"
|
||||
)))]
|
||||
{
|
||||
Err("Unsupported platform".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_arch() -> String {
|
||||
let arch = std::env::consts::ARCH;
|
||||
@@ -249,7 +360,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_log::Builder::new().build())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![unzip_file, get_arch, get_uid, prompt_password])
|
||||
.invoke_handler(tauri::generate_handler![unzip_file, get_arch, get_uid, prompt_password, stop_pid])
|
||||
.setup(|_app| Ok(()))
|
||||
// .setup(|app| {
|
||||
// if cfg!(debug_assertions) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Rclone UI",
|
||||
"mainBinaryName": "Rclone UI",
|
||||
"version": "1.9.1",
|
||||
"version": "1.9.2",
|
||||
"identifier": "com.stray.app",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
|
||||
@@ -13,7 +13,7 @@ import { sep } from '@tauri-apps/api/path'
|
||||
import { message, open } from '@tauri-apps/plugin-dialog'
|
||||
import { mkdir, readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
|
||||
import { UploadIcon } from 'lucide-react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { getConfigPath } from '../../lib/rclone/common'
|
||||
import { usePersistedStore } from '../../lib/store'
|
||||
import type { ConfigFile } from '../../types/config'
|
||||
@@ -33,77 +33,86 @@ export default function ConfigCreateDrawer({
|
||||
const [isPasswordCommand, setIsPasswordCommand] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const isEncrypted = useMemo(() => {
|
||||
return configContent?.includes('RCLONE_ENCRYPT_V0:')
|
||||
}, [configContent])
|
||||
const isEncrypted = configContent?.includes('RCLONE_ENCRYPT_V0:')
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async ({
|
||||
label,
|
||||
pass,
|
||||
passCommand,
|
||||
content,
|
||||
isPasswordCommand,
|
||||
isEncrypted,
|
||||
}: {
|
||||
label?: string
|
||||
pass?: string
|
||||
passCommand?: string
|
||||
content: string | null
|
||||
isPasswordCommand: boolean
|
||||
isEncrypted: boolean
|
||||
}) => {
|
||||
try {
|
||||
if (!label) {
|
||||
throw new Error('Label is required')
|
||||
}
|
||||
async function handleCreate({
|
||||
label,
|
||||
pass,
|
||||
passCommand,
|
||||
content,
|
||||
isPasswordCommand,
|
||||
isEncrypted,
|
||||
}: {
|
||||
label?: string
|
||||
pass?: string
|
||||
passCommand?: string
|
||||
content: string | null
|
||||
isPasswordCommand: boolean
|
||||
isEncrypted: boolean
|
||||
}) {
|
||||
if (!label) {
|
||||
await message('Label is required', {
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
throw new Error('Content is required')
|
||||
}
|
||||
if (!content) {
|
||||
await message('Content is required', {
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isEncrypted && isPasswordCommand && !passCommand) {
|
||||
throw new Error('Password command is required for encrypted configs')
|
||||
}
|
||||
if (isEncrypted && isPasswordCommand && !passCommand) {
|
||||
await message('Password command is required for encrypted configs', {
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
setIsSaving(true)
|
||||
|
||||
const generatedId = crypto.randomUUID()
|
||||
const savedPass = isPasswordCommand ? undefined : pass
|
||||
const savedPassCommand = isPasswordCommand ? passCommand : undefined
|
||||
|
||||
const configPath = await getConfigPath({ id: generatedId, validate: false })
|
||||
try {
|
||||
const generatedId = crypto.randomUUID()
|
||||
|
||||
await mkdir(configPath.replace(sep() + 'rclone.conf', ''), {
|
||||
recursive: true,
|
||||
})
|
||||
await writeTextFile(configPath, content)
|
||||
console.log('[handleCreate] saved config to', configPath)
|
||||
const configPath = await getConfigPath({ id: generatedId, validate: false })
|
||||
|
||||
usePersistedStore.getState().addConfigFile({
|
||||
id: generatedId,
|
||||
label,
|
||||
pass: isPasswordCommand ? undefined : pass,
|
||||
passCommand: isPasswordCommand ? passCommand : undefined,
|
||||
isEncrypted: isEncrypted,
|
||||
sync: undefined,
|
||||
})
|
||||
await mkdir(configPath.replace(sep() + 'rclone.conf', ''), {
|
||||
recursive: true,
|
||||
})
|
||||
await writeTextFile(configPath, content)
|
||||
console.log('[handleCreate] saved config to', configPath)
|
||||
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('[handleCreate] failed to save config', error)
|
||||
await message(
|
||||
error instanceof Error ? error.message : 'An unknown error occurred',
|
||||
{
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
)
|
||||
usePersistedStore.getState().addConfigFile({
|
||||
id: generatedId,
|
||||
label,
|
||||
pass: savedPass,
|
||||
passCommand: savedPassCommand,
|
||||
isEncrypted: isEncrypted,
|
||||
sync: undefined,
|
||||
})
|
||||
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('[handleCreate] failed to save config', error)
|
||||
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
})
|
||||
}
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
import { Button } from '@heroui/react'
|
||||
import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { readTextFile, writeTextFile } from '@tauri-apps/plugin-fs'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getConfigPath } from '../../lib/rclone/common'
|
||||
import { usePersistedStore } from '../../lib/store'
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function ConfigEditDrawer({
|
||||
isOpen: boolean
|
||||
}) {
|
||||
const configFiles = usePersistedStore((state) => state.configFiles)
|
||||
const initialConfig = useMemo(() => configFiles.find((c) => c.id === id), [configFiles, id])
|
||||
const initialConfig = configFiles.find((c) => c.id === id)
|
||||
|
||||
const [configLabel, setConfigLabel] = useState<string | null>(null)
|
||||
const [configPass, setConfigPass] = useState<string | null>(null)
|
||||
@@ -35,72 +35,81 @@ export default function ConfigEditDrawer({
|
||||
const [isPasswordCommand, setIsPasswordCommand] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const isEncrypted = useMemo(() => {
|
||||
return configContent?.includes('RCLONE_ENCRYPT_V0:')
|
||||
}, [configContent])
|
||||
const isEncrypted = configContent?.includes('RCLONE_ENCRYPT_V0:')
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
async ({
|
||||
label,
|
||||
pass,
|
||||
content,
|
||||
passCommand,
|
||||
isPasswordCommand,
|
||||
isEncrypted,
|
||||
}: {
|
||||
label?: string
|
||||
pass?: string
|
||||
content?: string
|
||||
passCommand?: string
|
||||
isPasswordCommand?: boolean
|
||||
isEncrypted?: boolean
|
||||
}) => {
|
||||
if (!id) return
|
||||
async function handleUpdate({
|
||||
label,
|
||||
pass,
|
||||
content,
|
||||
passCommand,
|
||||
isPasswordCommand,
|
||||
isEncrypted,
|
||||
}: {
|
||||
label?: string
|
||||
pass?: string
|
||||
content?: string
|
||||
passCommand?: string
|
||||
isPasswordCommand?: boolean
|
||||
isEncrypted?: boolean
|
||||
}) {
|
||||
if (!id) return
|
||||
|
||||
try {
|
||||
if (!label) {
|
||||
throw new Error('Label is required')
|
||||
}
|
||||
if (!label) {
|
||||
await message('Label is required', {
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
throw new Error('Content is required')
|
||||
}
|
||||
if (!content) {
|
||||
await message('Content is required', {
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isEncrypted && isPasswordCommand && !passCommand) {
|
||||
throw new Error('Password command is required for encrypted configs')
|
||||
}
|
||||
if (isEncrypted && isPasswordCommand && !passCommand) {
|
||||
await message('Password command is required for encrypted configs', {
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
setIsSaving(true)
|
||||
|
||||
const configPath = await getConfigPath({ id: id, validate: true })
|
||||
await writeTextFile(configPath, content)
|
||||
const savedPass = isPasswordCommand ? undefined : pass
|
||||
const savedPassCommand = isPasswordCommand ? passCommand : undefined
|
||||
|
||||
usePersistedStore.getState().updateConfigFile(id, {
|
||||
label,
|
||||
pass: isPasswordCommand ? undefined : pass,
|
||||
passCommand: isPasswordCommand ? passCommand : undefined,
|
||||
isEncrypted: isEncrypted,
|
||||
})
|
||||
try {
|
||||
const configPath = await getConfigPath({ id: id, validate: true })
|
||||
await writeTextFile(configPath, content)
|
||||
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('[handleUpdate] failed to save config', error)
|
||||
await message(
|
||||
error instanceof Error ? error.message : 'An unknown error occurred',
|
||||
{
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
},
|
||||
[id, onClose]
|
||||
)
|
||||
usePersistedStore.getState().updateConfigFile(id, {
|
||||
label,
|
||||
pass: savedPass,
|
||||
passCommand: savedPassCommand,
|
||||
isEncrypted: isEncrypted,
|
||||
})
|
||||
|
||||
const initializeConfig = useCallback(async () => {
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('[handleUpdate] failed to save config', error)
|
||||
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
})
|
||||
}
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
async function initializeConfig() {
|
||||
if (!initialConfig) {
|
||||
return
|
||||
}
|
||||
@@ -112,7 +121,7 @@ export default function ConfigEditDrawer({
|
||||
setConfigPass(initialConfig.pass || null)
|
||||
setConfigPassCommand(initialConfig.passCommand || null)
|
||||
setIsPasswordCommand(initialConfig.passCommand !== null)
|
||||
}, [initialConfig])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && configContent === null && configLabel === null) {
|
||||
@@ -126,7 +135,8 @@ export default function ConfigEditDrawer({
|
||||
setConfigPassCommand(null)
|
||||
setIsPasswordCommand(false)
|
||||
}
|
||||
}, [isOpen, initializeConfig, configLabel, configContent])
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: <compiler>
|
||||
}, [isOpen, configLabel, initializeConfig, configContent])
|
||||
|
||||
if (!id) {
|
||||
return null
|
||||
|
||||
@@ -12,7 +12,7 @@ import { sep } from '@tauri-apps/api/path'
|
||||
import { message, open } from '@tauri-apps/plugin-dialog'
|
||||
import { exists, readTextFile } from '@tauri-apps/plugin-fs'
|
||||
import { UploadIcon } from 'lucide-react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { usePersistedStore } from '../../lib/store'
|
||||
import type { ConfigFile } from '../../types/config'
|
||||
|
||||
@@ -31,65 +31,77 @@ export default function ConfigSyncDrawer({
|
||||
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async ({
|
||||
label,
|
||||
pass,
|
||||
isEncrypted,
|
||||
passCommand,
|
||||
sync,
|
||||
isPasswordCommand,
|
||||
}: {
|
||||
label?: string
|
||||
sync?: string
|
||||
isEncrypted?: boolean
|
||||
pass?: string
|
||||
passCommand?: string
|
||||
isPasswordCommand?: boolean
|
||||
}) => {
|
||||
try {
|
||||
if (!label) {
|
||||
throw new Error('Label is required')
|
||||
}
|
||||
async function handleCreate({
|
||||
label,
|
||||
pass,
|
||||
isEncrypted,
|
||||
passCommand,
|
||||
sync,
|
||||
isPasswordCommand,
|
||||
}: {
|
||||
label?: string
|
||||
sync?: string
|
||||
isEncrypted?: boolean
|
||||
pass?: string
|
||||
passCommand?: string
|
||||
isPasswordCommand?: boolean
|
||||
}) {
|
||||
if (!label) {
|
||||
await message('Label is required', {
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!sync) {
|
||||
throw new Error('Path is required')
|
||||
}
|
||||
if (!sync) {
|
||||
await message('Path is required', {
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (isEncrypted && isPasswordCommand && !passCommand) {
|
||||
throw new Error('Password command is required for encrypted configs')
|
||||
}
|
||||
if (isEncrypted && isPasswordCommand && !passCommand) {
|
||||
await message('Password command is required for encrypted configs', {
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
setIsSaving(true)
|
||||
|
||||
const generatedId = crypto.randomUUID()
|
||||
const savedPass = isPasswordCommand ? undefined : pass
|
||||
const savedPassCommand = isPasswordCommand ? passCommand : undefined
|
||||
const savedIsEncrypted = isEncrypted || false
|
||||
|
||||
usePersistedStore.getState().addConfigFile({
|
||||
id: generatedId,
|
||||
label,
|
||||
isEncrypted: isEncrypted || false,
|
||||
pass: isPasswordCommand ? undefined : pass,
|
||||
passCommand: isPasswordCommand ? passCommand : undefined,
|
||||
sync,
|
||||
})
|
||||
try {
|
||||
const generatedId = crypto.randomUUID()
|
||||
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('[handleCreate] failed to save config', error)
|
||||
await message(
|
||||
error instanceof Error ? error.message : 'An unknown error occurred',
|
||||
{
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
}
|
||||
)
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
)
|
||||
usePersistedStore.getState().addConfigFile({
|
||||
id: generatedId,
|
||||
label,
|
||||
isEncrypted: savedIsEncrypted,
|
||||
pass: savedPass,
|
||||
passCommand: savedPassCommand,
|
||||
sync,
|
||||
})
|
||||
|
||||
onClose()
|
||||
} catch (error) {
|
||||
console.error('[handleCreate] failed to save config', error)
|
||||
await message(error instanceof Error ? error.message : 'An unknown error occurred', {
|
||||
title: 'Failed to save config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
})
|
||||
}
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -209,6 +221,14 @@ export default function ConfigSyncDrawer({
|
||||
variant="light"
|
||||
size="sm"
|
||||
onPress={async () => {
|
||||
const getSavedLabel = (
|
||||
a: any,
|
||||
b: any,
|
||||
c: any
|
||||
) => {
|
||||
return a || b || c
|
||||
}
|
||||
|
||||
try {
|
||||
let selectedFolder = await open({
|
||||
directory: true,
|
||||
@@ -228,9 +248,15 @@ export default function ConfigSyncDrawer({
|
||||
}
|
||||
|
||||
if (!(await exists(selectedFolder))) {
|
||||
throw new Error(
|
||||
'The selected path does not exist'
|
||||
await message(
|
||||
'The selected path does not exist',
|
||||
{
|
||||
title: 'Failed to sync config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const configPath =
|
||||
@@ -241,23 +267,36 @@ export default function ConfigSyncDrawer({
|
||||
try {
|
||||
content = await readTextFile(configPath)
|
||||
} catch {
|
||||
throw new Error(
|
||||
'Could not find an rclone.conf file in the selected folder'
|
||||
await message(
|
||||
'Could not find an rclone.conf file in the selected folder',
|
||||
{
|
||||
title: 'Failed to sync config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
throw new Error(
|
||||
'Empty rclone.conf file'
|
||||
await message(
|
||||
'Empty rclone.conf file',
|
||||
{
|
||||
title: 'Failed to sync config',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
}
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setConfig({
|
||||
...config,
|
||||
label:
|
||||
config.label ||
|
||||
configPath.split(sep()).pop() ||
|
||||
'New Config',
|
||||
label: getSavedLabel(
|
||||
config.label,
|
||||
configPath.split(sep()).pop(),
|
||||
'New Config'
|
||||
),
|
||||
sync: selectedFolder,
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Button, Input, Select, SelectItem } from '@heroui/react'
|
||||
import cronstrue from 'cronstrue'
|
||||
import { ClockIcon, XIcon } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface CronEditorProps {
|
||||
expression: string | null
|
||||
@@ -21,12 +21,9 @@ const DEFAULT_OPTIONS = ['*', '*/5', '*/10', '*/15', '*/30']
|
||||
export default function CronEditor({ expression, onChange }: CronEditorProps) {
|
||||
const [cronExpression, setCronExpression] = useState(expression)
|
||||
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = useMemo(
|
||||
() => (cronExpression || '* * * * *').split(' '),
|
||||
[cronExpression]
|
||||
)
|
||||
const [minute, hour, dayOfMonth, month, dayOfWeek] = (cronExpression || '* * * * *').split(' ')
|
||||
|
||||
const readableDescription = useMemo(() => {
|
||||
const readableDescription = (() => {
|
||||
if (!cronExpression) return 'This task is not scheduled'
|
||||
let description: string
|
||||
try {
|
||||
@@ -37,19 +34,16 @@ export default function CronEditor({ expression, onChange }: CronEditorProps) {
|
||||
description = 'Invalid cron expression'
|
||||
}
|
||||
return description
|
||||
}, [cronExpression])
|
||||
})()
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(field: string, value: string) => {
|
||||
const parts = (cronExpression || '* * * * *').split(' ')
|
||||
const index = ['minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek'].indexOf(field)
|
||||
if (index !== -1) {
|
||||
parts[index] = value
|
||||
setCronExpression(parts.join(' '))
|
||||
}
|
||||
},
|
||||
[cronExpression]
|
||||
)
|
||||
function handleFieldChange(field: string, value: string) {
|
||||
const parts = (cronExpression || '* * * * *').split(' ')
|
||||
const index = ['minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek'].indexOf(field)
|
||||
if (index !== -1) {
|
||||
parts[index] = value
|
||||
setCronExpression(parts.join(' '))
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
onChange(cronExpression)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Chip, Textarea, Tooltip } from '@heroui/react'
|
||||
import { LockKeyholeIcon, LockOpenIcon, XIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { replaceSmartQuotes } from '../../lib/format'
|
||||
|
||||
export default function OptionsSection({
|
||||
@@ -43,12 +43,9 @@ export default function OptionsSection({
|
||||
}
|
||||
}, [optionsJson])
|
||||
|
||||
const isOptionAdded = useCallback(
|
||||
(option: string) => {
|
||||
return options[option] !== undefined
|
||||
},
|
||||
[options]
|
||||
)
|
||||
function isOptionAdded(option: string) {
|
||||
return options[option] !== undefined
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-2">
|
||||
|
||||
+181
-195
@@ -6,7 +6,7 @@ import { sep } from '@tauri-apps/api/path'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { readDir } from '@tauri-apps/plugin-fs'
|
||||
import { ArrowDownUp, FolderOpen, XIcon } from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useDebounce } from 'use-debounce'
|
||||
import { isRemotePath } from '../../lib/fs'
|
||||
import { listPath } from '../../lib/rclone/api'
|
||||
@@ -150,11 +150,9 @@ export function MultiPathFinder({
|
||||
setDestPath(temp)
|
||||
}
|
||||
|
||||
const isSwapDisabled = useMemo(() => {
|
||||
return sourcePaths.length !== 1 || !destPath
|
||||
}, [sourcePaths, destPath])
|
||||
const isSwapDisabled = sourcePaths.length !== 1 || !destPath
|
||||
|
||||
const swapDisabledReason = useMemo(() => {
|
||||
const swapDisabledReason = (() => {
|
||||
if (sourcePaths.length === 0) {
|
||||
return 'No source selected'
|
||||
}
|
||||
@@ -165,7 +163,7 @@ export function MultiPathFinder({
|
||||
return 'No destination selected'
|
||||
}
|
||||
return 'Swap sources'
|
||||
}, [sourcePaths, destPath])
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -242,103 +240,96 @@ export function PathField({
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const visibleSuggestions = useMemo(() => {
|
||||
if (!showSuggestions) {
|
||||
return []
|
||||
}
|
||||
return suggestions
|
||||
}, [suggestions, showSuggestions])
|
||||
const visibleSuggestions = showSuggestions ? suggestions : []
|
||||
|
||||
const fetchSuggestions = useCallback(
|
||||
async (searchPath: string) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
async function fetchSuggestions(searchPath: string) {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
// console.log('fetching suggestions for', path, field)
|
||||
// console.log('fetching suggestions for', path, field)
|
||||
|
||||
try {
|
||||
// If path is empty, show list of remotes
|
||||
if (!searchPath) {
|
||||
const remoteItems = remotes.map((remote) => ({
|
||||
IsDir: true,
|
||||
Name: remote + ':/',
|
||||
Path: remote + ':/',
|
||||
}))
|
||||
setSuggestions(remoteItems)
|
||||
return
|
||||
}
|
||||
let cleanedPath = searchPath
|
||||
const extraSlash = cleanedPath.endsWith(sep()) ? '' : sep()
|
||||
|
||||
// Fetch suggestions for local paths
|
||||
if (!isRemotePath(searchPath)) {
|
||||
let localEntries: Awaited<ReturnType<typeof readDir>> = []
|
||||
let cleanedPath = searchPath
|
||||
try {
|
||||
// If path is empty, show list of remotes
|
||||
if (!searchPath) {
|
||||
const remoteItems = remotes.map((remote) => ({
|
||||
IsDir: true,
|
||||
Name: remote + ':/',
|
||||
Path: remote + ':/',
|
||||
}))
|
||||
setSuggestions(remoteItems)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch suggestions for local paths
|
||||
if (!isRemotePath(searchPath)) {
|
||||
let localEntries: Awaited<ReturnType<typeof readDir>> = []
|
||||
|
||||
try {
|
||||
localEntries = await readDir(cleanedPath)
|
||||
} catch (err) {
|
||||
// most likely due to the path being a file
|
||||
console.error('Failed to fetch local suggestions:', err)
|
||||
try {
|
||||
// we also retry in case the last part of the path is wrong
|
||||
cleanedPath = searchPath.split(sep()).slice(0, -1).join(sep())
|
||||
localEntries = await readDir(cleanedPath)
|
||||
} catch (err) {
|
||||
// most likely due to the path being a file
|
||||
console.error('Failed to fetch local suggestions:', err)
|
||||
try {
|
||||
// we also retry in case the last part of the path is wrong
|
||||
cleanedPath = searchPath.split(sep()).slice(0, -1).join(sep())
|
||||
localEntries = await readDir(cleanedPath)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch local suggestions (again):', err)
|
||||
}
|
||||
console.error('Failed to fetch local suggestions (again):', err)
|
||||
}
|
||||
|
||||
const extraSlash = cleanedPath.endsWith(sep()) ? '' : sep()
|
||||
|
||||
const localSuggestions = localEntries
|
||||
.filter((entry) => !entry.isSymlink)
|
||||
.map((entry) => ({
|
||||
IsDir: entry.isDirectory,
|
||||
Name: entry.name,
|
||||
Path: `${cleanedPath}${extraSlash}${entry.name}`,
|
||||
}))
|
||||
|
||||
setSuggestions(localSuggestions)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Split the path into remote and path parts
|
||||
const [remote, ...pathParts] = searchPath.split(':/')
|
||||
if (!remote) {
|
||||
throw new Error('Invalid remote path format')
|
||||
}
|
||||
const localSuggestions = localEntries
|
||||
.filter((entry) => !entry.isSymlink)
|
||||
.map((entry) => ({
|
||||
IsDir: entry.isDirectory,
|
||||
Name: entry.name,
|
||||
Path: `${cleanedPath}${extraSlash}${entry.name}`,
|
||||
}))
|
||||
|
||||
let remotePath = pathParts.join('/')
|
||||
if (remotePath.endsWith('/')) {
|
||||
remotePath = remotePath.slice(0, -1)
|
||||
}
|
||||
|
||||
const items = await listPath(remote, remotePath, {
|
||||
noModTime: true,
|
||||
noMimeType: true,
|
||||
})
|
||||
|
||||
const suggestionsWithRemote = items.map((item) => ({
|
||||
IsDir: item.IsDir,
|
||||
Name: item.Path,
|
||||
Path: `${remote}:/${item.Path}`,
|
||||
}))
|
||||
|
||||
setSuggestions(suggestionsWithRemote)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch suggestions:', err)
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to fetch suggestions'
|
||||
setError(errorMessage)
|
||||
setSuggestions([])
|
||||
} finally {
|
||||
setSuggestions(localSuggestions)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
},
|
||||
[remotes]
|
||||
)
|
||||
|
||||
const handleBrowse = useCallback(async () => {
|
||||
// Split the path into remote and path parts
|
||||
const [remote, ...pathParts] = searchPath.split(':/')
|
||||
if (!remote) {
|
||||
// throw new Error('Invalid remote path format')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let remotePath = pathParts.join('/')
|
||||
if (remotePath.endsWith('/')) {
|
||||
remotePath = remotePath.slice(0, -1)
|
||||
}
|
||||
|
||||
const items = await listPath(remote, remotePath, {
|
||||
noModTime: true,
|
||||
noMimeType: true,
|
||||
})
|
||||
|
||||
const suggestionsWithRemote = items.map((item) => ({
|
||||
IsDir: item.IsDir,
|
||||
Name: item.Path,
|
||||
Path: `${remote}:/${item.Path}`,
|
||||
}))
|
||||
|
||||
setSuggestions(suggestionsWithRemote)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch suggestions:', err)
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch suggestions'
|
||||
setError(errorMessage)
|
||||
setSuggestions([])
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
async function handleBrowse() {
|
||||
try {
|
||||
await lockWindows()
|
||||
const selected = await open({
|
||||
@@ -356,7 +347,7 @@ export function PathField({
|
||||
console.error('Failed to open folder picker:', err)
|
||||
setError('Failed to open folder picker')
|
||||
}
|
||||
}, [path, setPath])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
@@ -379,6 +370,7 @@ export function PathField({
|
||||
cancelTimeout = null
|
||||
}
|
||||
}
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: <compiler>
|
||||
}, [debouncedPath, fetchSuggestions, showSuggestions])
|
||||
|
||||
return (
|
||||
@@ -460,7 +452,7 @@ export function MultiPathField({
|
||||
|
||||
const fieldRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const isMultiple = useMemo(() => paths.length > 1, [paths])
|
||||
const isMultiple = paths.length > 1
|
||||
|
||||
const [suggestions, setSuggestions] = useState<
|
||||
{
|
||||
@@ -472,128 +464,120 @@ export function MultiPathField({
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const fetchSuggestions = useCallback(
|
||||
async (path: string) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
async function fetchSuggestions(path: string) {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
// console.log('fetching suggestions for', path, field)
|
||||
// console.log('fetching suggestions for', path, field)
|
||||
|
||||
try {
|
||||
// If path is empty, show list of remotes
|
||||
if (!path) {
|
||||
const remoteItems = remotes.map((remote) => ({
|
||||
IsDir: true,
|
||||
Name: remote + ':/',
|
||||
Path: remote + ':/',
|
||||
}))
|
||||
setSuggestions(remoteItems)
|
||||
return
|
||||
}
|
||||
let cleanedPath = path
|
||||
const extraSlash = cleanedPath.endsWith(sep()) ? '' : sep()
|
||||
|
||||
// Fetch suggestions for local paths
|
||||
if (!isRemotePath(path)) {
|
||||
let localEntries: Awaited<ReturnType<typeof readDir>> = []
|
||||
let cleanedPath = path
|
||||
try {
|
||||
// If path is empty, show list of remotes
|
||||
if (!path) {
|
||||
const remoteItems = remotes.map((remote) => ({
|
||||
IsDir: true,
|
||||
Name: remote + ':/',
|
||||
Path: remote + ':/',
|
||||
}))
|
||||
setSuggestions(remoteItems)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Fetch suggestions for local paths
|
||||
if (!isRemotePath(path)) {
|
||||
let localEntries: Awaited<ReturnType<typeof readDir>> = []
|
||||
|
||||
try {
|
||||
localEntries = await readDir(cleanedPath)
|
||||
} catch (err) {
|
||||
// most likely due to the path being a file
|
||||
console.error('Failed to fetch local suggestions:', err)
|
||||
try {
|
||||
// we also retry in case the last part of the path is wrong
|
||||
cleanedPath = path.split(sep()).slice(0, -1).join(sep())
|
||||
localEntries = await readDir(cleanedPath)
|
||||
} catch (err) {
|
||||
// most likely due to the path being a file
|
||||
console.error('Failed to fetch local suggestions:', err)
|
||||
try {
|
||||
// we also retry in case the last part of the path is wrong
|
||||
cleanedPath = path.split(sep()).slice(0, -1).join(sep())
|
||||
localEntries = await readDir(cleanedPath)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch local suggestions (again):', err)
|
||||
}
|
||||
console.error('Failed to fetch local suggestions (again):', err)
|
||||
}
|
||||
|
||||
const extraSlash = cleanedPath.endsWith(sep()) ? '' : sep()
|
||||
|
||||
const localSuggestions = localEntries
|
||||
.filter((entry) => !entry.isSymlink)
|
||||
.map((entry) => ({
|
||||
IsDir: entry.isDirectory,
|
||||
Name: entry.name,
|
||||
Path: `${cleanedPath}${extraSlash}${entry.name}`,
|
||||
}))
|
||||
|
||||
setSuggestions(localSuggestions)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Split the path into remote and path parts
|
||||
const [remote, ...pathParts] = path.split(':/')
|
||||
if (!remote) {
|
||||
throw new Error('Invalid remote path format')
|
||||
}
|
||||
const localSuggestions = localEntries
|
||||
.filter((entry) => !entry.isSymlink)
|
||||
.map((entry) => ({
|
||||
IsDir: entry.isDirectory,
|
||||
Name: entry.name,
|
||||
Path: `${cleanedPath}${extraSlash}${entry.name}`,
|
||||
}))
|
||||
|
||||
let remotePath = pathParts.join('/')
|
||||
if (remotePath.endsWith('/')) {
|
||||
remotePath = remotePath.slice(0, -1)
|
||||
}
|
||||
|
||||
const items = await listPath(remote, remotePath, {
|
||||
noModTime: true,
|
||||
noMimeType: true,
|
||||
})
|
||||
|
||||
const suggestionsWithRemote = items.map((item) => ({
|
||||
IsDir: item.IsDir,
|
||||
Name: item.Path,
|
||||
Path: `${remote}:/${item.Path}`,
|
||||
}))
|
||||
|
||||
setSuggestions(suggestionsWithRemote)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch suggestions:', err)
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Failed to fetch suggestions'
|
||||
setError(errorMessage)
|
||||
setSuggestions([])
|
||||
} finally {
|
||||
setSuggestions(localSuggestions)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
},
|
||||
[remotes]
|
||||
)
|
||||
|
||||
const handleBrowse = useCallback(
|
||||
async (type: 'file' | 'folder') => {
|
||||
try {
|
||||
await lockWindows()
|
||||
const selected = await open({
|
||||
directory: type === 'folder',
|
||||
multiple: type === 'file',
|
||||
defaultPath: paths?.[0],
|
||||
title: type === 'file' ? 'Select one or more files' : 'Select a folder',
|
||||
})
|
||||
await unlockWindows()
|
||||
if (selected) {
|
||||
if (typeof selected === 'string') {
|
||||
setPaths([selected])
|
||||
} else {
|
||||
setPaths(selected)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
console.error('Failed to open folder picker:', err)
|
||||
setError('Failed to open folder picker')
|
||||
// Split the path into remote and path parts
|
||||
const [remote, ...pathParts] = path.split(':/')
|
||||
if (!remote) {
|
||||
// throw new Error('Invalid remote path format')
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
},
|
||||
[paths, setPaths]
|
||||
)
|
||||
|
||||
const visibleSuggestions = useMemo(() => {
|
||||
if (!showSuggestions) {
|
||||
return []
|
||||
let remotePath = pathParts.join('/')
|
||||
if (remotePath.endsWith('/')) {
|
||||
remotePath = remotePath.slice(0, -1)
|
||||
}
|
||||
|
||||
const items = await listPath(remote, remotePath, {
|
||||
noModTime: true,
|
||||
noMimeType: true,
|
||||
})
|
||||
|
||||
const suggestionsWithRemote = items.map((item) => ({
|
||||
IsDir: item.IsDir,
|
||||
Name: item.Path,
|
||||
Path: `${remote}:/${item.Path}`,
|
||||
}))
|
||||
|
||||
setSuggestions(suggestionsWithRemote)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch suggestions:', err)
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to fetch suggestions'
|
||||
setError(errorMessage)
|
||||
setSuggestions([])
|
||||
}
|
||||
return suggestions
|
||||
}, [suggestions, showSuggestions])
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
async function handleBrowse(type: 'file' | 'folder') {
|
||||
const defaultPath = paths?.[0]
|
||||
const title = type === 'file' ? 'Select one or more files' : 'Select a folder'
|
||||
try {
|
||||
await lockWindows()
|
||||
const selected = await open({
|
||||
directory: type === 'folder',
|
||||
multiple: type === 'file',
|
||||
defaultPath: defaultPath,
|
||||
title: title,
|
||||
})
|
||||
await unlockWindows()
|
||||
if (selected) {
|
||||
if (typeof selected === 'string') {
|
||||
setPaths([selected])
|
||||
} else {
|
||||
setPaths(selected)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
Sentry.captureException(err)
|
||||
console.error('Failed to open folder picker:', err)
|
||||
setError('Failed to open folder picker')
|
||||
}
|
||||
}
|
||||
|
||||
const visibleSuggestions = showSuggestions ? suggestions : []
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSuggestions) {
|
||||
@@ -605,6 +589,7 @@ export function MultiPathField({
|
||||
if (paths?.[0]) {
|
||||
fetchSuggestions(paths?.[0])
|
||||
}
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: <compiler>
|
||||
}, [paths, fetchSuggestions, showSuggestions, isMultiple])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -631,6 +616,7 @@ export function MultiPathField({
|
||||
cancelTimeout = null
|
||||
}
|
||||
}
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: <compiler>
|
||||
}, [debouncedPath, fetchSuggestions, showSuggestions, isMultiple])
|
||||
|
||||
return (
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { message } from '@tauri-apps/plugin-dialog'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import { ChevronDown, ChevronUp, RefreshCcwIcon } from 'lucide-react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { createRemote } from '../../lib/rclone/api'
|
||||
import { getBackends } from '../../lib/rclone/api'
|
||||
import { useStore } from '../../lib/store'
|
||||
@@ -29,20 +29,24 @@ export default function RemoteCreateDrawer({
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [backends, setBackends] = useState<Backend[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
getBackends().then((b) => {
|
||||
setBackends(b)
|
||||
})
|
||||
}, [])
|
||||
const currentBackend = config.type ? backends.find((b) => b.Name === config.type) : null
|
||||
|
||||
const handleTypeChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const currentBackendFields = currentBackend
|
||||
? (currentBackend.Options as BackendOption[]).filter(
|
||||
(opt) =>
|
||||
!opt.Provider ||
|
||||
(opt.Provider.includes(config.provider) && !opt.Provider.startsWith('!'))
|
||||
) || []
|
||||
: []
|
||||
|
||||
function handleTypeChange(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const newType = e.target.value
|
||||
|
||||
// preserve name when resetting type
|
||||
setConfig((prev) => ({ name: prev.name, type: newType }))
|
||||
}
|
||||
|
||||
const renderField = (option: BackendOption) => {
|
||||
function renderField(option: BackendOption) {
|
||||
// Skip rendering if the field should be hidden
|
||||
if (option.Hide !== 0) return null
|
||||
|
||||
@@ -151,26 +155,25 @@ export default function RemoteCreateDrawer({
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
setIsSaving(true)
|
||||
|
||||
try {
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const data: Record<string, string | boolean> = {}
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const data: Record<string, string | boolean> = {}
|
||||
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value.toString().trim() === '') continue
|
||||
if (
|
||||
e.currentTarget[key] instanceof HTMLInputElement &&
|
||||
e.currentTarget[key].type === 'checkbox'
|
||||
) {
|
||||
data[key] = (e.currentTarget[key] as HTMLInputElement).checked
|
||||
} else {
|
||||
data[key] = value.toString()
|
||||
}
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (value.toString().trim() === '') continue
|
||||
if (
|
||||
e.currentTarget[key] instanceof HTMLInputElement &&
|
||||
e.currentTarget[key].type === 'checkbox'
|
||||
) {
|
||||
data[key] = (e.currentTarget[key] as HTMLInputElement).checked
|
||||
} else {
|
||||
data[key] = value.toString()
|
||||
}
|
||||
|
||||
}
|
||||
try {
|
||||
const name = data.name as string
|
||||
const type = data.type as string
|
||||
const parameters = Object.fromEntries(
|
||||
@@ -187,27 +190,15 @@ export default function RemoteCreateDrawer({
|
||||
title: 'Could not create remote',
|
||||
kind: 'error',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
const currentBackend = useMemo(() => {
|
||||
if (!config.type) return null
|
||||
return backends.find((b) => b.Name === config.type)
|
||||
}, [config, backends])
|
||||
|
||||
const currentBackendFields = useMemo(() => {
|
||||
if (!currentBackend) return []
|
||||
const options =
|
||||
(currentBackend?.Options as BackendOption[]).filter(
|
||||
(opt) =>
|
||||
!opt.Provider ||
|
||||
(opt.Provider.includes(config.provider) && !opt.Provider.startsWith('!'))
|
||||
) || []
|
||||
|
||||
return options
|
||||
}, [config.provider, currentBackend])
|
||||
useEffect(() => {
|
||||
getBackends().then((b) => {
|
||||
setBackends(b)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
WavesLadderIcon,
|
||||
WrenchIcon,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
getConfigFlags,
|
||||
getCopyFlags,
|
||||
@@ -83,7 +83,7 @@ export default function RemoteDefaultsDrawer({
|
||||
setVfsOptionsJson(JSON.stringify(remoteConfig?.vfsDefaults, null, 2) || '{}')
|
||||
}, [remoteConfigList, remoteName])
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
async function handleSubmit() {
|
||||
if (!config) {
|
||||
console.log('No config')
|
||||
return
|
||||
@@ -99,30 +99,40 @@ export default function RemoteDefaultsDrawer({
|
||||
|
||||
try {
|
||||
const configOptions = JSON.parse(configOptionsJson)
|
||||
newConfig.configDefaults =
|
||||
Object.keys(configOptions).length > 0 ? configOptions : undefined
|
||||
// Extract conditional logic outside try/catch for React Compiler compatibility
|
||||
if (Object.keys(configOptions).length > 0) {
|
||||
newConfig.configDefaults = configOptions
|
||||
}
|
||||
|
||||
step = 'Copy'
|
||||
const copyOptions = JSON.parse(copyOptionsJson)
|
||||
newConfig.copyDefaults = Object.keys(copyOptions).length > 0 ? copyOptions : undefined
|
||||
if (Object.keys(copyOptions).length > 0) {
|
||||
newConfig.copyDefaults = copyOptions
|
||||
}
|
||||
|
||||
step = 'Mount'
|
||||
const mountOptions = JSON.parse(mountOptionsJson)
|
||||
newConfig.mountDefaults =
|
||||
Object.keys(mountOptions).length > 0 ? mountOptions : undefined
|
||||
if (Object.keys(mountOptions).length > 0) {
|
||||
newConfig.mountDefaults = mountOptions
|
||||
}
|
||||
|
||||
step = 'Sync'
|
||||
const syncOptions = JSON.parse(syncOptionsJson)
|
||||
newConfig.syncDefaults = Object.keys(syncOptions).length > 0 ? syncOptions : undefined
|
||||
if (Object.keys(syncOptions).length > 0) {
|
||||
newConfig.syncDefaults = syncOptions
|
||||
}
|
||||
|
||||
step = 'Filter'
|
||||
const filterOptions = JSON.parse(filterOptionsJson)
|
||||
newConfig.filterDefaults =
|
||||
Object.keys(filterOptions).length > 0 ? filterOptions : undefined
|
||||
if (Object.keys(filterOptions).length > 0) {
|
||||
newConfig.filterDefaults = filterOptions
|
||||
}
|
||||
|
||||
step = 'VFS'
|
||||
const vfsOptions = JSON.parse(vfsOptionsJson)
|
||||
newConfig.vfsDefaults = Object.keys(vfsOptions).length > 0 ? vfsOptions : undefined
|
||||
if (Object.keys(vfsOptions).length > 0) {
|
||||
newConfig.vfsDefaults = vfsOptions
|
||||
}
|
||||
} catch {
|
||||
await message(`Could not update remote, error parsing ${step} options`, {
|
||||
title: 'Invalid JSON',
|
||||
@@ -142,20 +152,9 @@ export default function RemoteDefaultsDrawer({
|
||||
title: 'Could not update remote',
|
||||
kind: 'error',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [
|
||||
config,
|
||||
copyOptionsJson,
|
||||
filterOptionsJson,
|
||||
syncOptionsJson,
|
||||
vfsOptionsJson,
|
||||
mountOptionsJson,
|
||||
configOptionsJson,
|
||||
remoteName,
|
||||
mergeRemoteConfig,
|
||||
])
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -547,6 +546,7 @@ export default function RemoteDefaultsDrawer({
|
||||
globalOptions={
|
||||
globalOptions['main' as keyof typeof globalOptions]
|
||||
}
|
||||
rows={15}
|
||||
getAvailableOptions={getCopyFlags}
|
||||
/>
|
||||
</AccordionItem>
|
||||
|
||||
@@ -36,7 +36,11 @@ export default function RemoteEditDrawer({
|
||||
setConfig(remoteInfo)
|
||||
// Find the current backend based on the type
|
||||
const backend = backends.find((b) => b.Name === remoteInfo.type)
|
||||
setCurrentBackend(backend || null)
|
||||
if (backend) {
|
||||
setCurrentBackend(backend)
|
||||
} else {
|
||||
setCurrentBackend(null)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load remote config:', error)
|
||||
}
|
||||
@@ -122,34 +126,34 @@ export default function RemoteEditDrawer({
|
||||
e.preventDefault()
|
||||
setIsSaving(true)
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const data: Record<string, string | boolean> = {}
|
||||
const changedValues: Record<string, string | boolean> = {}
|
||||
|
||||
// First collect all form values
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (
|
||||
e.currentTarget[key] instanceof HTMLInputElement &&
|
||||
e.currentTarget[key].type === 'checkbox'
|
||||
) {
|
||||
data[key] = (e.currentTarget[key] as HTMLInputElement).checked
|
||||
} else {
|
||||
data[key] = value.toString()
|
||||
}
|
||||
}
|
||||
|
||||
// Compare with original config and only include changed values
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// if the value is empty and the key is not in the config, skip it
|
||||
if (!config?.[key] && value.toString().trim() === '') {
|
||||
continue
|
||||
}
|
||||
if (config[key] !== value) {
|
||||
changedValues[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const data: Record<string, string | boolean> = {}
|
||||
const changedValues: Record<string, string | boolean> = {}
|
||||
|
||||
// First collect all form values
|
||||
for (const [key, value] of formData.entries()) {
|
||||
if (
|
||||
e.currentTarget[key] instanceof HTMLInputElement &&
|
||||
e.currentTarget[key].type === 'checkbox'
|
||||
) {
|
||||
data[key] = (e.currentTarget[key] as HTMLInputElement).checked
|
||||
} else {
|
||||
data[key] = value.toString()
|
||||
}
|
||||
}
|
||||
|
||||
// Compare with original config and only include changed values
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// if the value is empty and the key is not in the config, skip it
|
||||
if (!config?.[key] && value.toString().trim() === '') {
|
||||
continue
|
||||
}
|
||||
if (config[key] !== value) {
|
||||
changedValues[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Only update if there are changes
|
||||
if (Object.keys(changedValues).length > 0) {
|
||||
await updateRemote(remoteName, changedValues)
|
||||
@@ -164,9 +168,8 @@ export default function RemoteEditDrawer({
|
||||
title: 'Could not update remote',
|
||||
kind: 'error',
|
||||
})
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
setIsSaving(false)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||
+31
-25
@@ -12,7 +12,7 @@ import {
|
||||
PlayIcon,
|
||||
WrenchIcon,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { getRemoteName } from '../../lib/format'
|
||||
import { isRemotePath } from '../../lib/fs'
|
||||
@@ -155,24 +155,24 @@ export default function Copy() {
|
||||
}
|
||||
}, [copyOptionsJson, filterOptionsJson, configOptionsJson])
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
const buttonText = (() => {
|
||||
if (isLoading) return 'STARTING...'
|
||||
if (!sources || sources.length === 0) return 'Please select a source path'
|
||||
if (!dest) return 'Please select a destination path'
|
||||
if (sources[0] === dest) return 'Source and destination cannot be the same'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
return 'START COPY'
|
||||
}, [isLoading, jsonError, sources, dest])
|
||||
})()
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
const buttonIcon = (() => {
|
||||
if (isLoading) return
|
||||
if (!sources || sources.length === 0 || !dest || sources[0] === dest)
|
||||
return <FoldersIcon className="w-5 h-5" />
|
||||
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
|
||||
return <PlayIcon className="w-5 h-5" />
|
||||
}, [isLoading, jsonError, sources, dest])
|
||||
})()
|
||||
|
||||
const handleStartCopy = useCallback(async () => {
|
||||
async function handleStartCopy() {
|
||||
setIsLoading(true)
|
||||
|
||||
if (!sources || sources.length === 0 || !dest) {
|
||||
@@ -183,9 +183,9 @@ export default function Copy() {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// check local paths exists
|
||||
for (const source of sources) {
|
||||
// check local paths exists
|
||||
for (const source of sources) {
|
||||
try {
|
||||
if (!isRemotePath(source)) {
|
||||
const sourceExists = await exists(source)
|
||||
if (sourceExists) {
|
||||
@@ -198,8 +198,8 @@ export default function Copy() {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!isRemotePath(dest)) {
|
||||
const destExists = await exists(dest)
|
||||
@@ -227,9 +227,15 @@ export default function Copy() {
|
||||
filterOptions &&
|
||||
('IncludeRule' in filterOptions || 'IncludeFrom' in filterOptions)
|
||||
) {
|
||||
throw new Error(
|
||||
'Include rules are not supported when the input is one or multiple files'
|
||||
await message(
|
||||
'Include rules are not supported when the input is one or multiple files',
|
||||
{
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
}
|
||||
)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const mergedConfig = {
|
||||
@@ -274,17 +280,17 @@ export default function Copy() {
|
||||
const failedPaths: Record<string, string> = {}
|
||||
|
||||
for (const source of sources) {
|
||||
const customFilterOptions = isFolder
|
||||
? filterOptions
|
||||
: {
|
||||
...filterOptions,
|
||||
IncludeRule: [source.split('/').pop()!],
|
||||
}
|
||||
|
||||
// Use parent folder path if the input is a file
|
||||
const customSource = isFolder ? source : source.split('/').slice(0, -1).join('/')
|
||||
|
||||
try {
|
||||
const customFilterOptions = isFolder
|
||||
? filterOptions
|
||||
: {
|
||||
...filterOptions,
|
||||
IncludeRule: [source.split('/').pop()!],
|
||||
}
|
||||
|
||||
// Use parent folder path if the input is a file
|
||||
const customSource = isFolder ? source : source.split('/').slice(0, -1).join('/')
|
||||
|
||||
const jobId = await startCopy({
|
||||
srcFs: customSource,
|
||||
dstFs: dest,
|
||||
@@ -361,7 +367,7 @@ export default function Copy() {
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}, [sources, dest, copyOptions, filterOptions, cronExpression, configOptions])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10 pt-10">
|
||||
@@ -392,7 +398,7 @@ export default function Copy() {
|
||||
optionsJson={copyOptionsJson}
|
||||
setOptionsJson={setCopyOptionsJson}
|
||||
getAvailableOptions={getCopyFlags}
|
||||
rows={18}
|
||||
rows={15}
|
||||
isLocked={copyOptionsLocked}
|
||||
setIsLocked={setCopyOptionsLocked}
|
||||
/>
|
||||
|
||||
+12
-22
@@ -5,7 +5,7 @@ import CronExpressionParser from 'cron-parser'
|
||||
import cronstrue from 'cronstrue'
|
||||
import { formatDistance } from 'date-fns'
|
||||
import { AlertCircleIcon, Clock7Icon, PauseIcon, PlayIcon, Trash2Icon } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { buildReadablePath } from '../../lib/format'
|
||||
import { usePersistedStore } from '../../lib/store'
|
||||
import { openWindow } from '../../lib/window'
|
||||
@@ -63,15 +63,17 @@ function TaskCard({ task }: { task: ScheduledTask }) {
|
||||
const removeScheduledTask = usePersistedStore((state) => state.removeScheduledTask)
|
||||
const updateScheduledTask = usePersistedStore((state) => state.updateScheduledTask)
|
||||
|
||||
const nextRun = useMemo(() => {
|
||||
const parsed = CronExpressionParser.parse(task.cron)
|
||||
if (!parsed.hasNext()) {
|
||||
return null
|
||||
}
|
||||
return parsed.next().toDate()
|
||||
}, [task.cron])
|
||||
const parsed = CronExpressionParser.parse(task.cron)
|
||||
const nextRun = parsed.hasNext() ? parsed.next().toDate() : null
|
||||
|
||||
const lastRunLabel = useMemo(() => {
|
||||
const nextRunLabel = nextRun
|
||||
? (() => {
|
||||
const distance = formatDistance(nextRun, new Date(), { addSuffix: true })
|
||||
return distance.charAt(0).toUpperCase() + distance.slice(1)
|
||||
})()
|
||||
: 'Never'
|
||||
|
||||
const lastRunLabel = (() => {
|
||||
if (task.isRunning) {
|
||||
return 'Running now'
|
||||
}
|
||||
@@ -82,19 +84,7 @@ function TaskCard({ task }: { task: ScheduledTask }) {
|
||||
return distance.charAt(0).toUpperCase() + distance.slice(1)
|
||||
}
|
||||
return 'Never'
|
||||
}, [task.isRunning, task.lastRun])
|
||||
|
||||
const nextRunLabel = useMemo(() => {
|
||||
if (!nextRun) {
|
||||
return 'Never'
|
||||
}
|
||||
|
||||
const distance = formatDistance(nextRun, new Date(), {
|
||||
addSuffix: true,
|
||||
})
|
||||
|
||||
return distance.charAt(0).toUpperCase() + distance.slice(1)
|
||||
}, [nextRun])
|
||||
})()
|
||||
|
||||
return (
|
||||
<Card
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
PlayIcon,
|
||||
WrenchIcon,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { getRemoteName } from '../../lib/format'
|
||||
import {
|
||||
@@ -115,7 +115,7 @@ export default function Delete() {
|
||||
}
|
||||
}, [filterOptionsJson, configOptionsJson])
|
||||
|
||||
const handleStartDelete = useCallback(async () => {
|
||||
async function handleStartDelete() {
|
||||
setIsLoading(true)
|
||||
|
||||
if (!sourceFs) {
|
||||
@@ -123,6 +123,7 @@ export default function Delete() {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
})
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -163,30 +164,30 @@ export default function Delete() {
|
||||
title: 'Success',
|
||||
okLabel: 'OK',
|
||||
})
|
||||
setIsLoading(false)
|
||||
} catch (error) {
|
||||
await message(`Failed to start delete job, ${error}`, {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [sourceFs, filterOptions, rmDirs, cronExpression, configOptions])
|
||||
}
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
const buttonText = (() => {
|
||||
if (isLoading) return 'STARTING...'
|
||||
if (!sourceFs || sourceFs.length === 0) return 'Please select a source path'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
return 'START DELETE'
|
||||
}, [isLoading, jsonError, sourceFs])
|
||||
})()
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
const buttonIcon = (() => {
|
||||
if (isLoading) 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" />
|
||||
}, [isLoading, jsonError, sourceFs])
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10 pt-10">
|
||||
|
||||
+4
-3
@@ -3,7 +3,7 @@ import { Button, Chip, Divider, Progress, Spinner } from '@heroui/react'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { ask } from '@tauri-apps/plugin-dialog'
|
||||
import { Trash2Icon } from 'lucide-react'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { buildReadablePath, formatBytes } from '../../lib/format'
|
||||
import { listJobs, stopJob } from '../../lib/rclone/api'
|
||||
@@ -20,14 +20,14 @@ export default function Jobs() {
|
||||
|
||||
const [busyIds, setBusyIds] = useState<number[]>([])
|
||||
|
||||
const fetchJobs = useCallback(async () => {
|
||||
async function fetchJobs() {
|
||||
const jobs = await listJobs()
|
||||
|
||||
console.log('jobs', JSON.stringify(jobs, null, 2))
|
||||
|
||||
setJobs(jobs)
|
||||
setIsInitialLoad(false)
|
||||
}, [])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchJobs()
|
||||
@@ -44,6 +44,7 @@ export default function Jobs() {
|
||||
clearInterval(interval)
|
||||
unlisten.then((u) => u())
|
||||
}
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: <compiler>
|
||||
}, [fetchJobs])
|
||||
|
||||
if (isInitialLoad) {
|
||||
|
||||
+29
-24
@@ -13,7 +13,7 @@ import {
|
||||
WavesLadderIcon,
|
||||
WrenchIcon,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { isDirectoryEmpty } from '../../lib/fs'
|
||||
import {
|
||||
@@ -142,36 +142,39 @@ export default function Mount() {
|
||||
}
|
||||
}, [mountOptionsJson, vfsOptionsJson, filterOptionsJson, configOptionsJson])
|
||||
|
||||
const handleStartMount = useCallback(async () => {
|
||||
async function handleStartMount() {
|
||||
if (!dest || !source) return
|
||||
|
||||
setIsLoading(true)
|
||||
|
||||
// Extract all conditional logic outside try/catch for React Compiler compatibility
|
||||
const currentPlatform = platform()
|
||||
const needsVolumeName = ['windows', 'macos'].includes(currentPlatform)
|
||||
const _mountOptions = { ...mountOptions }
|
||||
|
||||
// Check if volume name exists and generate if needed - all outside try/catch
|
||||
const hasVolumeName = 'VolumeName' in _mountOptions && _mountOptions.VolumeName
|
||||
if (!hasVolumeName && needsVolumeName) {
|
||||
const segments = source.split('/').filter(Boolean)
|
||||
console.log('[Mount] segments', segments)
|
||||
|
||||
const sourcePath =
|
||||
segments.length === 1 ? segments[0].replace(/:/g, '') : segments.pop()
|
||||
console.log('[Mount] sourcePath', sourcePath)
|
||||
|
||||
_mountOptions.VolumeName = `${sourcePath}-${Math.random().toString(36).substring(2, 3).toUpperCase()}`
|
||||
}
|
||||
|
||||
try {
|
||||
const needsPlugin = await needsMountPlugin()
|
||||
if (needsPlugin) {
|
||||
console.log('[Mount] Mount plugin not installed')
|
||||
await dialogGetMountPlugin()
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
console.log('[Mount] Mount plugin installed')
|
||||
|
||||
const _mountOptions = { ...mountOptions }
|
||||
|
||||
if (
|
||||
(!('VolumeName' in _mountOptions) || !_mountOptions.VolumeName) &&
|
||||
['windows', 'macos'].includes(platform())
|
||||
) {
|
||||
const segments = source.split('/').filter(Boolean)
|
||||
console.log('[Mount] segments', segments)
|
||||
|
||||
const sourcePath =
|
||||
segments.length === 1 ? segments[0].replace(/:/g, '') : segments.pop()
|
||||
console.log('[Mount] sourcePath', sourcePath)
|
||||
|
||||
_mountOptions.VolumeName = `${sourcePath}-${Math.random().toString(36).substring(2, 3).toUpperCase()}`
|
||||
}
|
||||
|
||||
let directoryExists: boolean | undefined
|
||||
|
||||
try {
|
||||
@@ -193,6 +196,7 @@ export default function Mount() {
|
||||
kind: 'error',
|
||||
})
|
||||
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -211,6 +215,7 @@ export default function Mount() {
|
||||
kind: 'error',
|
||||
}
|
||||
)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -227,6 +232,7 @@ export default function Mount() {
|
||||
setIsMounted(true)
|
||||
|
||||
await triggerTrayRebuild()
|
||||
setIsLoading(false)
|
||||
} catch (err) {
|
||||
console.error('[Mount] Failed to start mount:', err)
|
||||
const errorMessage =
|
||||
@@ -235,12 +241,11 @@ export default function Mount() {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [source, dest, mountOptions, vfsOptions, filterOptions, configOptions])
|
||||
}
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
const buttonText = (() => {
|
||||
if (isLoading) return 'MOUNTING...'
|
||||
if (isMounted) return 'MOUNTED'
|
||||
if (!source) return 'Please select a source path'
|
||||
@@ -248,14 +253,14 @@ export default function Mount() {
|
||||
if (source === dest) return 'Source and destination cannot be the same'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
return 'START MOUNT'
|
||||
}, [isLoading, jsonError, source, dest, isMounted])
|
||||
})()
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
const buttonIcon = (() => {
|
||||
if (isLoading || isMounted) return
|
||||
if (!source || !dest || source === dest) return <FoldersIcon className="w-5 h-5" />
|
||||
if (jsonError) return <AlertOctagonIcon className="w-4 h-4" />
|
||||
return <PlayIcon className="w-4 h-4 fill-current" />
|
||||
}, [isLoading, jsonError, source, dest, isMounted])
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10 pt-10">
|
||||
|
||||
+29
-31
@@ -12,7 +12,7 @@ import {
|
||||
PlayIcon,
|
||||
WrenchIcon,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { getRemoteName } from '../../lib/format'
|
||||
import { isRemotePath } from '../../lib/fs'
|
||||
@@ -143,7 +143,7 @@ export default function Move() {
|
||||
}
|
||||
}, [moveOptionsJson, filterOptionsJson, configOptionsJson])
|
||||
|
||||
const handleStartMove = useCallback(async () => {
|
||||
async function handleStartMove() {
|
||||
setIsLoading(true)
|
||||
|
||||
if (!sources || sources.length === 0 || !dest) {
|
||||
@@ -151,12 +151,13 @@ export default function Move() {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
})
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// check local paths exists
|
||||
for (const source of sources) {
|
||||
// check local paths exists
|
||||
for (const source of sources) {
|
||||
try {
|
||||
if (!isRemotePath(source)) {
|
||||
const sourceExists = await exists(source)
|
||||
if (sourceExists) {
|
||||
@@ -169,8 +170,8 @@ export default function Move() {
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!isRemotePath(dest)) {
|
||||
const destExists = await exists(dest)
|
||||
@@ -198,9 +199,15 @@ export default function Move() {
|
||||
filterOptions &&
|
||||
('IncludeRule' in filterOptions || 'IncludeFrom' in filterOptions)
|
||||
) {
|
||||
throw new Error(
|
||||
'Include rules are not supported when the input is one or multiple files'
|
||||
await message(
|
||||
'Include rules are not supported when the input is one or multiple files',
|
||||
{
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
}
|
||||
)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const mergedConfig = {
|
||||
@@ -247,16 +254,16 @@ export default function Move() {
|
||||
const failedPaths: Record<string, string> = {}
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const customFilterOptions = isFolder
|
||||
? filterOptions
|
||||
: {
|
||||
...filterOptions,
|
||||
IncludeRule: [source.split('/').pop()!],
|
||||
}
|
||||
const customFilterOptions = isFolder
|
||||
? filterOptions
|
||||
: {
|
||||
...filterOptions,
|
||||
IncludeRule: [source.split('/').pop()!],
|
||||
}
|
||||
const customSource = isFolder ? source : source.split('/').slice(0, -1).join('/')
|
||||
|
||||
try {
|
||||
// Use parent folder path if the input is a file
|
||||
const customSource = isFolder ? source : source.split('/').slice(0, -1).join('/')
|
||||
|
||||
const jobId = await startMove({
|
||||
srcFs: customSource,
|
||||
@@ -336,33 +343,24 @@ export default function Move() {
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
}, [
|
||||
sources,
|
||||
dest,
|
||||
moveOptions,
|
||||
filterOptions,
|
||||
cronExpression,
|
||||
createEmptySrcDirs,
|
||||
deleteEmptyDstDirs,
|
||||
configOptions,
|
||||
])
|
||||
}
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
const buttonText = (() => {
|
||||
if (isLoading) return 'STARTING...'
|
||||
if (!sources || sources.length === 0) return 'Please select a source path'
|
||||
if (!dest) return 'Please select a destination path'
|
||||
if (sources[0] === dest) return 'Source and destination cannot be the same'
|
||||
if (jsonError) return 'Invalid JSON for ' + jsonError.toUpperCase() + ' options'
|
||||
return 'START MOVE'
|
||||
}, [isLoading, jsonError, sources, dest])
|
||||
})()
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
const buttonIcon = (() => {
|
||||
if (isLoading) return
|
||||
if (!sources || sources.length === 0 || !dest || sources[0] === dest)
|
||||
return <FoldersIcon className="w-5 h-5" />
|
||||
if (jsonError) return <AlertOctagonIcon className="w-5 h-5" />
|
||||
return <PlayIcon className="w-5 h-5" />
|
||||
}, [isLoading, jsonError, sources, dest])
|
||||
})()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10 pt-10">
|
||||
|
||||
+29
-30
@@ -48,14 +48,7 @@ import {
|
||||
ServerIcon,
|
||||
Trash2Icon,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
type DetailedHTMLProps,
|
||||
type HTMLAttributes,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { type DetailedHTMLProps, type HTMLAttributes, useEffect, useRef, useState } from 'react'
|
||||
import { revokeMachineLicense, validateLicense } from '../../lib/license'
|
||||
import { deleteRemote, getVersion as getCliVersion, getVersion } from '../../lib/rclone/api'
|
||||
import { getConfigPath, getDefaultPaths } from '../../lib/rclone/common'
|
||||
@@ -269,7 +262,7 @@ function GeneralSection() {
|
||||
const [isWorkingUpdate, setIsWorkingUpdate] = useState(false)
|
||||
const [update, setUpdate] = useState<Update | null>(null)
|
||||
|
||||
const updateCallback = useCallback(async () => {
|
||||
async function updateCallback() {
|
||||
if (!update) {
|
||||
try {
|
||||
console.log('checking for updates')
|
||||
@@ -300,9 +293,8 @@ function GeneralSection() {
|
||||
} catch (e) {
|
||||
Sentry.captureException(e)
|
||||
console.error(e)
|
||||
} finally {
|
||||
setIsWorkingUpdate(false)
|
||||
}
|
||||
setIsWorkingUpdate(false)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -355,7 +347,7 @@ function GeneralSection() {
|
||||
}
|
||||
|
||||
await getCurrentWindow().emit('close-app')
|
||||
}, [update])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// needed since the first value from the persisted store is undefined
|
||||
@@ -661,9 +653,8 @@ function LicenseSection() {
|
||||
okLabel: 'Ok',
|
||||
cancelLabel: '',
|
||||
})
|
||||
} finally {
|
||||
setIsActivating(false)
|
||||
}
|
||||
setIsActivating(false)
|
||||
|
||||
await message('Your license has been successfully activated.', {
|
||||
title: 'Congrats!',
|
||||
@@ -720,9 +711,8 @@ function LicenseSection() {
|
||||
okLabel: 'Ok',
|
||||
cancelLabel: '',
|
||||
})
|
||||
} finally {
|
||||
setIsRevoking(false)
|
||||
}
|
||||
setIsRevoking(false)
|
||||
|
||||
await message('Your license has been successfully deactivated.', {
|
||||
title: 'License deactivated',
|
||||
@@ -929,10 +919,9 @@ function ConfigSection() {
|
||||
|
||||
const [isExportingId, setIsExportingId] = useState('')
|
||||
|
||||
const exportConfig = useCallback(async ({ id, label }: { id: string; label: string }) => {
|
||||
async function exportConfig({ id, label }: { id: string; label: string }) {
|
||||
setIsExportingId(id)
|
||||
try {
|
||||
setIsExportingId(id)
|
||||
|
||||
const configPath = await getConfigPath({ id: id, validate: true })
|
||||
const text = await readTextFile(configPath)
|
||||
|
||||
@@ -959,10 +948,9 @@ function ConfigSection() {
|
||||
kind: 'error',
|
||||
okLabel: 'OK',
|
||||
})
|
||||
} finally {
|
||||
setIsExportingId('')
|
||||
}
|
||||
}, [])
|
||||
setIsExportingId('')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -1177,7 +1165,7 @@ function AboutSection() {
|
||||
const isLoadingLogs = useRef(false)
|
||||
const [last30Lines, setLast30Lines] = useState<string[]>([])
|
||||
|
||||
const fetchInfo = useCallback(async () => {
|
||||
async function fetchInfo() {
|
||||
const defaultPaths = await getDefaultPaths()
|
||||
const version = await getVersion()
|
||||
const dirs = {
|
||||
@@ -1205,24 +1193,34 @@ function AboutSection() {
|
||||
isEncrypted: currentConfig?.isEncrypted!,
|
||||
},
|
||||
}
|
||||
}, [currentConfig])
|
||||
}
|
||||
|
||||
const fetchLogs = useCallback(async (logFilePath: string) => {
|
||||
async function fetchLogs(logFilePath: string) {
|
||||
if (isLoadingLogs.current) {
|
||||
return []
|
||||
}
|
||||
isLoadingLogs.current = true
|
||||
const logLines = await readTextFileLines(logFilePath)
|
||||
const lines = []
|
||||
for await (const line of logLines) {
|
||||
lines.push(line)
|
||||
|
||||
const lines: string[] = []
|
||||
const iterator = logLines[Symbol.asyncIterator]()
|
||||
let result = await iterator.next()
|
||||
|
||||
while (!result.done) {
|
||||
lines.push(result.value)
|
||||
if (lines.length > 35) {
|
||||
lines.splice(0, 5)
|
||||
}
|
||||
result = await iterator.next()
|
||||
}
|
||||
|
||||
isLoadingLogs.current = false
|
||||
return lines.slice(-30)
|
||||
}, [])
|
||||
return lines
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchInfo().then(setInfo)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: <compiler>
|
||||
}, [fetchInfo])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -1233,6 +1231,7 @@ function AboutSection() {
|
||||
return
|
||||
}
|
||||
fetchLogs(info.dirs.appLog + '/Rclone UI.log').then(setLast30Lines)
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: <compiler>
|
||||
}, [fetchLogs, info, last30Lines.length])
|
||||
|
||||
return (
|
||||
|
||||
+102
-103
@@ -11,7 +11,7 @@ import {
|
||||
PlayIcon,
|
||||
WrenchIcon,
|
||||
} from 'lucide-react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { getRemoteName } from '../../lib/format'
|
||||
import { isRemotePath } from '../../lib/fs'
|
||||
@@ -57,93 +57,23 @@ export default function Sync() {
|
||||
|
||||
const [currentGlobalOptions, setCurrentGlobalOptions] = useState<any[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
setConfigOptionsJson(JSON.stringify(RCLONE_CONFIG_DEFAULTS, null, 2))
|
||||
const buttonText = (() => {
|
||||
if (isLoading) 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'
|
||||
return 'START SYNC'
|
||||
})()
|
||||
|
||||
return () => {
|
||||
setConfigOptionsJson('{}')
|
||||
}
|
||||
}, [])
|
||||
const buttonIcon = (() => {
|
||||
if (isLoading) 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" />
|
||||
})()
|
||||
|
||||
useEffect(() => {
|
||||
getCurrentGlobalFlags().then((flags) => setCurrentGlobalOptions(flags))
|
||||
}, [])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: when unlocking, we don't want to re-run the effect
|
||||
useEffect(() => {
|
||||
const storeData = usePersistedStore.getState()
|
||||
|
||||
const sourceRemote = getRemoteName(source)
|
||||
const destRemote = getRemoteName(dest)
|
||||
|
||||
let mergedSyncDefaults = {}
|
||||
let mergedFilterDefaults = {}
|
||||
let mergedConfigDefaults = {}
|
||||
|
||||
// Helper function to merge defaults from a remote
|
||||
const mergeRemoteDefaults = (remote: string | null) => {
|
||||
if (!remote) return
|
||||
|
||||
const remoteConfig = storeData.remoteConfigList?.[remote] || {}
|
||||
|
||||
if (remoteConfig.syncDefaults) {
|
||||
mergedSyncDefaults = {
|
||||
...mergedSyncDefaults,
|
||||
...remoteConfig.syncDefaults,
|
||||
}
|
||||
}
|
||||
|
||||
if (remoteConfig.filterDefaults) {
|
||||
mergedFilterDefaults = {
|
||||
...mergedFilterDefaults,
|
||||
...remoteConfig.filterDefaults,
|
||||
}
|
||||
}
|
||||
|
||||
if (remoteConfig.configDefaults) {
|
||||
mergedConfigDefaults = {
|
||||
...mergedConfigDefaults,
|
||||
...remoteConfig.configDefaults,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only merge defaults for remote paths
|
||||
if (sourceRemote) mergeRemoteDefaults(sourceRemote)
|
||||
if (destRemote) mergeRemoteDefaults(destRemote)
|
||||
|
||||
if (Object.keys(mergedSyncDefaults).length > 0 && !syncOptionsLocked) {
|
||||
setSyncOptionsJson(JSON.stringify(mergedSyncDefaults, null, 2))
|
||||
}
|
||||
|
||||
if (Object.keys(mergedFilterDefaults).length > 0 && !filterOptionsLocked) {
|
||||
setFilterOptionsJson(JSON.stringify(mergedFilterDefaults, null, 2))
|
||||
}
|
||||
|
||||
if (Object.keys(mergedConfigDefaults).length > 0 && !configOptionsLocked) {
|
||||
setConfigOptionsJson(JSON.stringify(mergedConfigDefaults, null, 2))
|
||||
}
|
||||
}, [source, dest])
|
||||
|
||||
useEffect(() => {
|
||||
let step: 'sync' | 'filter' | 'config' = 'sync'
|
||||
try {
|
||||
setSyncOptions(JSON.parse(syncOptionsJson))
|
||||
|
||||
step = 'filter'
|
||||
setFilterOptions(JSON.parse(filterOptionsJson))
|
||||
|
||||
step = 'config'
|
||||
setConfigOptions(JSON.parse(configOptionsJson))
|
||||
|
||||
setJsonError(null)
|
||||
} catch (error) {
|
||||
setJsonError(step)
|
||||
console.error(`Error parsing ${step} options:`, error)
|
||||
}
|
||||
}, [syncOptionsJson, filterOptionsJson, configOptionsJson])
|
||||
|
||||
const handleStartSync = useCallback(async () => {
|
||||
async function handleStartSync() {
|
||||
setIsLoading(true)
|
||||
|
||||
if (!source || !dest) {
|
||||
@@ -238,26 +168,95 @@ export default function Sync() {
|
||||
title: 'Error',
|
||||
kind: 'error',
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [source, dest, syncOptions, filterOptions, cronExpression, configOptions])
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
const buttonText = useMemo(() => {
|
||||
if (isLoading) 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'
|
||||
return 'START SYNC'
|
||||
}, [isLoading, jsonError, source, dest])
|
||||
useEffect(() => {
|
||||
setConfigOptionsJson(JSON.stringify(RCLONE_CONFIG_DEFAULTS, null, 2))
|
||||
|
||||
const buttonIcon = useMemo(() => {
|
||||
if (isLoading) 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" />
|
||||
}, [isLoading, jsonError, source, dest])
|
||||
return () => {
|
||||
setConfigOptionsJson('{}')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
getCurrentGlobalFlags().then((flags) => setCurrentGlobalOptions(flags))
|
||||
}, [])
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: when unlocking, we don't want to re-run the effect
|
||||
useEffect(() => {
|
||||
const storeData = usePersistedStore.getState()
|
||||
|
||||
const sourceRemote = getRemoteName(source)
|
||||
const destRemote = getRemoteName(dest)
|
||||
|
||||
let mergedSyncDefaults = {}
|
||||
let mergedFilterDefaults = {}
|
||||
let mergedConfigDefaults = {}
|
||||
|
||||
// Helper function to merge defaults from a remote
|
||||
const mergeRemoteDefaults = (remote: string | null) => {
|
||||
if (!remote) return
|
||||
|
||||
const remoteConfig = storeData.remoteConfigList?.[remote] || {}
|
||||
|
||||
if (remoteConfig.syncDefaults) {
|
||||
mergedSyncDefaults = {
|
||||
...mergedSyncDefaults,
|
||||
...remoteConfig.syncDefaults,
|
||||
}
|
||||
}
|
||||
|
||||
if (remoteConfig.filterDefaults) {
|
||||
mergedFilterDefaults = {
|
||||
...mergedFilterDefaults,
|
||||
...remoteConfig.filterDefaults,
|
||||
}
|
||||
}
|
||||
|
||||
if (remoteConfig.configDefaults) {
|
||||
mergedConfigDefaults = {
|
||||
...mergedConfigDefaults,
|
||||
...remoteConfig.configDefaults,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only merge defaults for remote paths
|
||||
if (sourceRemote) mergeRemoteDefaults(sourceRemote)
|
||||
if (destRemote) mergeRemoteDefaults(destRemote)
|
||||
|
||||
if (Object.keys(mergedSyncDefaults).length > 0 && !syncOptionsLocked) {
|
||||
setSyncOptionsJson(JSON.stringify(mergedSyncDefaults, null, 2))
|
||||
}
|
||||
|
||||
if (Object.keys(mergedFilterDefaults).length > 0 && !filterOptionsLocked) {
|
||||
setFilterOptionsJson(JSON.stringify(mergedFilterDefaults, null, 2))
|
||||
}
|
||||
|
||||
if (Object.keys(mergedConfigDefaults).length > 0 && !configOptionsLocked) {
|
||||
setConfigOptionsJson(JSON.stringify(mergedConfigDefaults, null, 2))
|
||||
}
|
||||
}, [source, dest])
|
||||
|
||||
useEffect(() => {
|
||||
let step: 'sync' | 'filter' | 'config' = 'sync'
|
||||
try {
|
||||
setSyncOptions(JSON.parse(syncOptionsJson))
|
||||
|
||||
step = 'filter'
|
||||
setFilterOptions(JSON.parse(filterOptionsJson))
|
||||
|
||||
step = 'config'
|
||||
setConfigOptions(JSON.parse(configOptionsJson))
|
||||
|
||||
setJsonError(null)
|
||||
} catch (error) {
|
||||
setJsonError(step)
|
||||
console.error(`Error parsing ${step} options:`, error)
|
||||
}
|
||||
}, [syncOptionsJson, filterOptionsJson, configOptionsJson])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen gap-10 pt-10">
|
||||
|
||||
+20
-1
@@ -5,7 +5,26 @@ const host = process.env.TAURI_DEV_HOST
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react({
|
||||
babel: {
|
||||
plugins: [
|
||||
[
|
||||
'babel-plugin-react-compiler',
|
||||
{
|
||||
target: '18',
|
||||
panicThreshold: 'all_errors',
|
||||
logger: {
|
||||
logEvent(filename, event) {
|
||||
console.log(`[Compiler] ${event.kind}: ${filename}`)
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
|
||||
esbuild: {
|
||||
supported: {
|
||||
|
||||
Reference in New Issue
Block a user