year of compiled react, bump to 1.9.2

Signed-off-by: FTCHD <144691102+FTCHD@users.noreply.github.com>
This commit is contained in:
FTCHD
2025-09-13 11:15:52 +02:00
parent 4a88e8a166
commit fc32e44803
29 changed files with 972 additions and 759 deletions
+3 -2
View File
@@ -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 }}
+26
View File
@@ -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))
}
+1
View File
@@ -433,6 +433,7 @@ async function handleTask(task: ScheduledTask) {
currentRunId: undefined,
error: err instanceof Error ? err.message : 'Unknown error',
})
} finally {
}
}
+23 -2
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -103,7 +103,7 @@ checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100"
[[package]]
name = "app"
version = "1.9.1"
version = "1.9.2"
dependencies = [
"cocoa",
"fix-path-env",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "app"
version = "1.9.1"
version = "1.9.2"
description = "A Tauri App"
authors = ["you"]
license = ""
+3
View File
@@ -311,6 +311,9 @@
},
{
"url": "https://checkout.link.com/**"
},
{
"url": "https://**.sentry.io/**"
}
]
},
+112 -1
View File
@@ -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) {
+1 -1
View File
@@ -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",
+72 -63
View File
@@ -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
+71 -61
View File
@@ -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
+103 -64
View File
@@ -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) {
+12 -18
View File
@@ -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)
+4 -7
View File
@@ -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
View File
@@ -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 (
+32 -41
View File
@@ -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
+24 -24
View File
@@ -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>
+33 -30
View File
@@ -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 (
+1
View File
@@ -11,6 +11,7 @@
* {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
cursor: default !important;
}
html {
+31 -25
View File
@@ -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
View File
@@ -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 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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: {