From fc32e44803fadceaa778522384594310b6999a12 Mon Sep 17 00:00:00 2001 From: FTCHD <144691102+FTCHD@users.noreply.github.com> Date: Sat, 13 Sep 2025 11:15:52 +0200 Subject: [PATCH] year of compiled react, bump to 1.9.2 Signed-off-by: FTCHD <144691102+FTCHD@users.noreply.github.com> --- .github/workflows/release.yml | 5 +- lib/rclone/api.ts | 26 ++ main.ts | 1 + package-lock.json | 25 +- package.json | 4 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/capabilities/default.json | 3 + src-tauri/src/lib.rs | 113 ++++++- src-tauri/tauri.conf.json | 2 +- src/components/ConfigCreateDrawer.tsx | 135 +++++---- src/components/ConfigEditDrawer.tsx | 132 +++++---- src/components/ConfigSyncDrawer.tsx | 167 +++++++---- src/components/CronEditor.tsx | 30 +- src/components/OptionsSection.tsx | 11 +- src/components/PathFinder.tsx | 376 ++++++++++++------------ src/components/RemoteCreateDrawer.tsx | 73 ++--- src/components/RemoteDefaultsDrawer.tsx | 48 +-- src/components/RemoteEditDrawer.tsx | 63 ++-- src/global.css | 1 + src/pages/Copy.tsx | 56 ++-- src/pages/Cron.tsx | 34 +-- src/pages/Delete.tsx | 17 +- src/pages/Jobs.tsx | 7 +- src/pages/Mount.tsx | 53 ++-- src/pages/Move.tsx | 60 ++-- src/pages/Settings.tsx | 59 ++-- src/pages/Sync.tsx | 205 +++++++------ vite.config.ts | 21 +- 29 files changed, 972 insertions(+), 759 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2cf5e3a..e2a34d9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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: "\"Rclone\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 }} diff --git a/lib/rclone/api.ts b/lib/rclone/api.ts index f100937..0b8ebc8 100644 --- a/lib/rclone/api.ts +++ b/lib/rclone/api.ts @@ -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) + + 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) + + const ftpFlags = r.ftp + + return ftpFlags.sort((a: any, b: any) => a.Name.localeCompare(b.Name)) +} diff --git a/main.ts b/main.ts index 41abc3f..bfa478b 100644 --- a/main.ts +++ b/main.ts @@ -433,6 +433,7 @@ async function handleTask(task: ScheduledTask) { currentRunId: undefined, error: err instanceof Error ? err.message : 'Unknown error', }) + } finally { } } diff --git a/package-lock.json b/package-lock.json index f18a1d7..bcc9313 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6618287..50b5617 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1caafe1..8cd1bee 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -103,7 +103,7 @@ checksum = "b0674a1ddeecb70197781e945de4b3b8ffb61fa939a5597bcf48503737663100" [[package]] name = "app" -version = "1.9.1" +version = "1.9.2" dependencies = [ "cocoa", "fix-path-env", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 183b0b5..b2deeb8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "app" -version = "1.9.1" +version = "1.9.2" description = "A Tauri App" authors = ["you"] license = "" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 3371d98..f68e12e 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -311,6 +311,9 @@ }, { "url": "https://checkout.link.com/**" + }, + { + "url": "https://**.sentry.io/**" } ] }, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f57b4c2..4b68805 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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) -> 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 + 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) { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ce63743..d887345 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/components/ConfigCreateDrawer.tsx b/src/components/ConfigCreateDrawer.tsx index adff922..1fc5996 100644 --- a/src/components/ConfigCreateDrawer.tsx +++ b/src/components/ConfigCreateDrawer.tsx @@ -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 ( 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(null) const [configPass, setConfigPass] = useState(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: + }, [isOpen, configLabel, initializeConfig, configContent]) if (!id) { return null diff --git a/src/components/ConfigSyncDrawer.tsx b/src/components/ConfigSyncDrawer.tsx index 3673c58..5f32187 100644 --- a/src/components/ConfigSyncDrawer.tsx +++ b/src/components/ConfigSyncDrawer.tsx @@ -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 ( { + 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) { diff --git a/src/components/CronEditor.tsx b/src/components/CronEditor.tsx index e026972..c0e5f11 100644 --- a/src/components/CronEditor.tsx +++ b/src/components/CronEditor.tsx @@ -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) diff --git a/src/components/OptionsSection.tsx b/src/components/OptionsSection.tsx index 2a43aa8..11db306 100644 --- a/src/components/OptionsSection.tsx +++ b/src/components/OptionsSection.tsx @@ -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 (
diff --git a/src/components/PathFinder.tsx b/src/components/PathFinder.tsx index 153c098..18be5c0 100644 --- a/src/components/PathFinder.tsx +++ b/src/components/PathFinder.tsx @@ -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 (
@@ -242,103 +240,96 @@ export function PathField({ const [isLoading, setIsLoading] = useState(false) const [error, setError] = useState(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> = [] - 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> = [] + + 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 | null = null @@ -379,6 +370,7 @@ export function PathField({ cancelTimeout = null } } + // biome-ignore lint/correctness/useExhaustiveDependencies: }, [debouncedPath, fetchSuggestions, showSuggestions]) return ( @@ -460,7 +452,7 @@ export function MultiPathField({ const fieldRef = useRef(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(false) const [error, setError] = useState(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> = [] - 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> = [] + + 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: }, [paths, fetchSuggestions, showSuggestions, isMultiple]) useEffect(() => { @@ -631,6 +616,7 @@ export function MultiPathField({ cancelTimeout = null } } + // biome-ignore lint/correctness/useExhaustiveDependencies: }, [debouncedPath, fetchSuggestions, showSuggestions, isMultiple]) return ( diff --git a/src/components/RemoteCreateDrawer.tsx b/src/components/RemoteCreateDrawer.tsx index 37b7658..ac94784 100644 --- a/src/components/RemoteCreateDrawer.tsx +++ b/src/components/RemoteCreateDrawer.tsx @@ -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([]) - useEffect(() => { - getBackends().then((b) => { - setBackends(b) - }) - }, []) + const currentBackend = config.type ? backends.find((b) => b.Name === config.type) : null - const handleTypeChange = (e: React.ChangeEvent) => { + const currentBackendFields = currentBackend + ? (currentBackend.Options as BackendOption[]).filter( + (opt) => + !opt.Provider || + (opt.Provider.includes(config.provider) && !opt.Provider.startsWith('!')) + ) || [] + : [] + + function handleTypeChange(e: React.ChangeEvent) { 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) => { + async function handleSubmit(e: React.FormEvent) { e.preventDefault() setIsSaving(true) - try { - const formData = new FormData(e.currentTarget) - const data: Record = {} + const formData = new FormData(e.currentTarget) + const data: Record = {} - 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 ( { + 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 ( diff --git a/src/components/RemoteEditDrawer.tsx b/src/components/RemoteEditDrawer.tsx index 0a8bf0b..9aa40fa 100644 --- a/src/components/RemoteEditDrawer.tsx +++ b/src/components/RemoteEditDrawer.tsx @@ -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 = {} + const changedValues: Record = {} + + // 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 = {} - const changedValues: Record = {} - - // 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 ( diff --git a/src/global.css b/src/global.css index 118b284..6d6bb09 100644 --- a/src/global.css +++ b/src/global.css @@ -11,6 +11,7 @@ * { -ms-overflow-style: none; /* IE and Edge */ scrollbar-width: none; /* Firefox */ + cursor: default !important; } html { diff --git a/src/pages/Copy.tsx b/src/pages/Copy.tsx index 185a6db..a708d45 100644 --- a/src/pages/Copy.tsx +++ b/src/pages/Copy.tsx @@ -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 if (jsonError) return return - }, [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 = {} 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 (
@@ -392,7 +398,7 @@ export default function Copy() { optionsJson={copyOptionsJson} setOptionsJson={setCopyOptionsJson} getAvailableOptions={getCopyFlags} - rows={18} + rows={15} isLocked={copyOptionsLocked} setIsLocked={setCopyOptionsLocked} /> diff --git a/src/pages/Cron.tsx b/src/pages/Cron.tsx index 7faa567..632f374 100644 --- a/src/pages/Cron.tsx +++ b/src/pages/Cron.tsx @@ -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 ( { + 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 if (jsonError) return return - }, [isLoading, jsonError, sourceFs]) + })() return (
diff --git a/src/pages/Jobs.tsx b/src/pages/Jobs.tsx index 2614237..624a8b5 100644 --- a/src/pages/Jobs.tsx +++ b/src/pages/Jobs.tsx @@ -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([]) - 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: }, [fetchJobs]) if (isInitialLoad) { diff --git a/src/pages/Mount.tsx b/src/pages/Mount.tsx index a89c851..f1a25fd 100644 --- a/src/pages/Mount.tsx +++ b/src/pages/Mount.tsx @@ -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 if (jsonError) return return - }, [isLoading, jsonError, source, dest, isMounted]) + })() return (
diff --git a/src/pages/Move.tsx b/src/pages/Move.tsx index e9462d5..157845a 100644 --- a/src/pages/Move.tsx +++ b/src/pages/Move.tsx @@ -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 = {} 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 if (jsonError) return return - }, [isLoading, jsonError, sources, dest]) + })() return (
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index c482124..ddb9571 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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(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 (
@@ -1177,7 +1165,7 @@ function AboutSection() { const isLoadingLogs = useRef(false) const [last30Lines, setLast30Lines] = useState([]) - 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: }, [fetchInfo]) useEffect(() => { @@ -1233,6 +1231,7 @@ function AboutSection() { return } fetchLogs(info.dirs.appLog + '/Rclone UI.log').then(setLast30Lines) + // biome-ignore lint/correctness/useExhaustiveDependencies: }, [fetchLogs, info, last30Lines.length]) return ( diff --git a/src/pages/Sync.tsx b/src/pages/Sync.tsx index ee6eb91..afca93b 100644 --- a/src/pages/Sync.tsx +++ b/src/pages/Sync.tsx @@ -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([]) - 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 + if (jsonError) return + return + })() - 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 - if (jsonError) return - return - }, [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 (
diff --git a/vite.config.ts b/vite.config.ts index 84eea89..726056f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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: {