From 5878a6ec4a118b2c4f336484bd1e754887cd2f0c Mon Sep 17 00:00:00 2001 From: Kirill Date: Sun, 29 Mar 2026 16:11:50 +0300 Subject: [PATCH] feat(ui): Added paralell instructions and window with operations progress --- Cargo.lock | 1 + Cargo.toml | 1 + src/operations.rs | 5 +- src/ui.rs | 749 +++++++++++++++++++++++++++++----------------- 4 files changed, 474 insertions(+), 282 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 389ba65..957a48c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,6 +15,7 @@ dependencies = [ "mockito", "reqwest", "rfd", + "serde", "serde_json", "serial_test", "tar", diff --git a/Cargo.toml b/Cargo.toml index d0f850e..5c4c4fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] tokio = { version = "1.0", features = ["full"] } reqwest = { version = "0.11", default-features = false, features = ["rustls-tls", "json", "stream"] } +serde = { version = "1.0", features = ["derive"] } futures = "0.3" flate2 = "1.0" tar = "0.4" diff --git a/src/operations.rs b/src/operations.rs index 95ca596..6532ede 100644 --- a/src/operations.rs +++ b/src/operations.rs @@ -1,8 +1,9 @@ use std::collections::HashMap; +use serde::{Serialize, Deserialize}; /// === Модуль для работы с удаленными хранилищами === -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Remote { pub name: String, pub r#type: String, @@ -13,7 +14,7 @@ pub mod remotes; /// === Модуль для работы с файлами и директориями === -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct FileInfo { pub name: String, pub path: String, diff --git a/src/ui.rs b/src/ui.rs index 953f2ee..68f6d76 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,9 +1,9 @@ //! Графический интерфейс для операций rclone. use eframe::egui; -use egui::{CentralPanel, ScrollArea, Window, Align2, Color32, Spinner}; +use egui::{CentralPanel, ScrollArea, Window, Align2, Color32, Spinner, ProgressBar, SidePanel}; use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::time::{Duration, Instant}; use tokio::runtime::Runtime; use crate::rclone_install::RcloneApp; use crate::operations::{self, FileInfo, Remote, CopyOptions, MoveOptions, DeleteOptions, FindOptions}; @@ -30,11 +30,25 @@ pub struct TransferProgress { pub file_name: String, } +/// Операция для отображения в правой панели. +#[derive(Clone)] +pub struct Operation { + pub id: u32, + pub description: String, + pub progress: f32, + pub status: String, + pub start_time: Instant, +} + /// Результат длительной операции. enum OperationResult { None, - Success(String), - Failure(String), + Success(u32, String), // (operation_id, message) + Failure(u32, String), // (operation_id, error) + FileList(Vec), + RemoteList(Vec), + SearchResults(Vec), + ProgressUpdate(u32, f32, String), } /// Главная структура UI. @@ -42,39 +56,40 @@ pub struct RcloneUI { rclone: Option>, state: AppState, error_message: Option, - + current_path: String, remote_list: Vec, current_files: Vec, selected_paths: Vec, - - transfer_source_list: Vec, // список выбранных источников для копирования/перемещения + + transfer_source_list: Vec, transfer_dest: String, transfer_progress: Option, - + search_pattern: String, search_results: Vec, - + show_transfer_dialog: bool, show_new_remote_dialog: bool, new_remote_name: String, new_remote_type: String, available_remote_types: Vec, new_remote_config: std::collections::HashMap, - + settings: AppSettings, - - background_working: bool, + + active_task_count: u32, operation_result: Arc>, - operation_id: u32, show_delete_remote_dialog: bool, remote_to_delete: Option, show_browser_warning: bool, pending_remote_creation: Option<(String, String)>, - - // Отложенные действия + pending_load_path: Option, pending_search_pattern: Option, + rclone_init_receiver: Option, String>>>, + active_operations: Vec, + next_op_id: u32, } #[derive(Clone)] @@ -98,6 +113,17 @@ impl Default for AppSettings { impl RcloneUI { pub fn new(_cc: &eframe::CreationContext<'_>) -> Self { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let rt = Runtime::new().unwrap(); + let result = rt.block_on(async { RcloneApp::new().await }); + let result_string = match result { + Ok(app) => Ok(Arc::new(app)), + Err(e) => Err(e.to_string()), + }; + let _ = tx.send(result_string); + }); + Self { rclone: None, state: AppState::Initializing, @@ -118,35 +144,41 @@ impl RcloneUI { available_remote_types: Vec::new(), new_remote_config: std::collections::HashMap::new(), settings: AppSettings::default(), - background_working: false, + active_task_count: 0, operation_result: Arc::new(Mutex::new(OperationResult::None)), - operation_id: 0, show_delete_remote_dialog: false, remote_to_delete: None, show_browser_warning: false, pending_remote_creation: None, pending_load_path: None, pending_search_pattern: None, + rclone_init_receiver: Some(rx), + active_operations: Vec::new(), + next_op_id: 1, } } - - fn init_rclone(&mut self) { - let rt = Runtime::new().unwrap(); - let rclone_result = rt.block_on(async { RcloneApp::new().await }); - match rclone_result { - Ok(rclone) => { - self.rclone = Some(Arc::new(rclone)); - self.load_remote_types(); - self.load_remotes(); - } - Err(e) => { - let error_msg = format!("Не удалось инициализировать rclone: {}", e); - self.error_message = Some(error_msg.clone()); - self.state = AppState::Error(error_msg); + + fn poll_rclone_init(&mut self) { + if let Some(receiver) = &self.rclone_init_receiver { + if let Ok(result) = receiver.try_recv() { + match result { + Ok(rclone) => { + self.rclone = Some(rclone); + self.load_remote_types(); + self.load_remotes(); + self.state = AppState::Ready; + } + Err(e) => { + let error_msg = format!("Не удалось инициализировать rclone: {}", e); + self.error_message = Some(error_msg.clone()); + self.state = AppState::Error(error_msg); + } + } + self.rclone_init_receiver = None; } } } - + fn load_remote_types(&mut self) { if let Some(rclone) = &self.rclone { match operations::info::backends(rclone) { @@ -160,101 +192,138 @@ impl RcloneUI { } } } - + fn load_remotes(&mut self) { - if let Some(rclone) = &self.rclone { - match operations::remotes::list(rclone) { - Ok(remotes) => { - self.remote_list = remotes; - self.state = AppState::Ready; - } - Err(e) => { - self.error_message = Some(e.clone()); - self.state = AppState::Error(e); - } - } + if self.active_task_count > 0 || self.rclone.is_none() { + return; + } + if let Some(rclone) = self.rclone.clone() { + self.active_task_count += 1; + let result_holder = self.operation_result.clone(); + std::thread::spawn(move || { + let remotes = operations::remotes::list(&rclone); + let mut holder = result_holder.lock().unwrap(); + *holder = match remotes { + Ok(list) => OperationResult::RemoteList(list), + Err(e) => OperationResult::Failure(0, e), + }; + }); } } - + fn load_files(&mut self, path: &str) { - if let Some(rclone) = &self.rclone { - match operations::files::list(rclone, path) { - Ok(files) => { - self.current_files = files; - self.current_path = path.to_string(); - self.search_results.clear(); - self.search_pattern.clear(); - // Очищаем выбранные файлы при смене директории - self.selected_paths.clear(); - } - Err(e) => self.error_message = Some(e), - } + if self.active_task_count > 0 || self.rclone.is_none() { + return; + } + if let Some(rclone) = self.rclone.clone() { + self.active_task_count += 1; + let path = path.to_string(); + let result_holder = self.operation_result.clone(); + std::thread::spawn(move || { + let files = operations::files::list(&rclone, &path); + let mut holder = result_holder.lock().unwrap(); + *holder = match files { + Ok(list) => OperationResult::FileList(list), + Err(e) => OperationResult::Failure(0, e), + }; + }); } } - - fn start_background_operation(&mut self, f: F) -> u32 - where - F: FnOnce() -> Result + Send + 'static, - { - self.background_working = true; - self.operation_id = self.operation_id.wrapping_add(1); - let op_id = self.operation_id; - let result_holder = self.operation_result.clone(); - std::thread::spawn(move || { - let result = f(); - let mut holder = result_holder.lock().unwrap(); - *holder = match result { - Ok(msg) => OperationResult::Success(msg), - Err(e) => OperationResult::Failure(e), - }; - }); - op_id + + fn search_files(&mut self, pattern: &str) { + if self.active_task_count > 0 || self.rclone.is_none() { + return; + } + if let Some(rclone) = self.rclone.clone() { + self.active_task_count += 1; + let pattern = pattern.to_string(); + let current_path = self.current_path.clone(); + let result_holder = self.operation_result.clone(); + std::thread::spawn(move || { + let options = FindOptions { + recursive: true, + max_results: 100, + }; + let results = operations::search::by_name(&rclone, ¤t_path, &pattern, &options); + let mut holder = result_holder.lock().unwrap(); + *holder = match results { + Ok(list) => OperationResult::SearchResults(list), + Err(e) => OperationResult::Failure(0, e), + }; + }); + } } - + fn poll_background_operation(&mut self) { - if !self.background_working { + if self.active_task_count == 0 { return; } let result = { let mut holder = self.operation_result.lock().unwrap(); - match std::mem::replace(&mut *holder, OperationResult::None) { - OperationResult::Success(msg) => Some(Ok(msg)), - OperationResult::Failure(e) => Some(Err(e)), - _ => None, - } + std::mem::replace(&mut *holder, OperationResult::None) }; - if let Some(res) = result { - self.background_working = false; - match res { - Ok(msg) => { - println!("Операция успешна: {}", msg); - self.load_remotes(); - let current_path = self.current_path.clone(); - if !current_path.is_empty() { - self.load_files(¤t_path); - } - self.error_message = None; - } - Err(e) => { - self.error_message = Some(e); + + match result { + OperationResult::Success(op_id, msg) => { + println!("✅ Операция {} успешна: {}", op_id, msg); + self.active_task_count = self.active_task_count.saturating_sub(1); + self.active_operations.retain(|op| op.id != op_id); + self.error_message = Some(msg); + } + OperationResult::Failure(op_id, e) => { + eprintln!("❌ Операция {} ошибка: {}", op_id, e); + self.active_task_count = self.active_task_count.saturating_sub(1); + self.active_operations.retain(|op| op.id != op_id); + self.error_message = Some(e); + } + OperationResult::FileList(files) => { + self.current_files = files; + self.active_task_count = self.active_task_count.saturating_sub(1); + } + OperationResult::RemoteList(remotes) => { + self.remote_list = remotes; + self.active_task_count = self.active_task_count.saturating_sub(1); + } + OperationResult::SearchResults(results) => { + self.search_results = results; + self.active_task_count = self.active_task_count.saturating_sub(1); + } + OperationResult::ProgressUpdate(op_id, progress, status) => { + if let Some(op) = self.active_operations.iter_mut().find(|op| op.id == op_id) { + op.progress = progress; + op.status = status; } } + OperationResult::None => {} } } - + fn delete_remote(&mut self, name: &str) { + if self.active_task_count > 0 || self.rclone.is_none() { + return; + } if let Some(rclone) = self.rclone.clone() { let name = name.to_string(); - self.start_background_operation(move || { - operations::remotes::delete(&rclone, &name) + let op_id = self.add_operation(format!("Удаление хранилища '{}'", name)); + self.active_task_count += 1; + let result_holder = self.operation_result.clone(); + std::thread::spawn(move || { + let result = operations::remotes::delete(&rclone, &name) .map(|_| format!("Хранилище '{}' удалено", name)) - .map_err(|e| format!("Ошибка удаления: {}", e)) + .map_err(|e| format!("Ошибка удаления: {}", e)); + let mut holder = result_holder.lock().unwrap(); + *holder = match result { + Ok(msg) => OperationResult::Success(op_id, msg), + Err(e) => OperationResult::Failure(op_id, e), + }; }); } } - - /// Массовое перемещение выбранных файлов/папок + fn perform_move_multiple(&mut self, sources: Vec, dest: &str) { + if self.active_task_count > 0 || self.rclone.is_none() { + return; + } if let Some(rclone) = self.rclone.clone() { self.state = AppState::Moving; let options = MoveOptions { @@ -263,14 +332,18 @@ impl RcloneUI { delete_empty_src_dirs: true, }; let dest = dest.to_string(); - self.start_background_operation(move || { + let op_id = self.add_operation(format!("Перемещение {} элементов", sources.len())); + self.active_task_count += 1; + let result_holder = self.operation_result.clone(); + std::thread::spawn(move || { let _rt = Runtime::new().unwrap(); let mut errors = Vec::new(); let mut moved_count = 0; let mut total_bytes = 0; - - for source in sources { - match operations::sync::move_files(&rclone, &source, &dest, &options) { + let total = sources.len() as f32; + + for (idx, source) in sources.iter().enumerate() { + match operations::sync::move_files(&rclone, source, &dest, &options) { Ok(stats) => { moved_count += 1; total_bytes += stats.transferred; @@ -279,19 +352,31 @@ impl RcloneUI { errors.push(format!("{}: {}", source, e)); } } + let progress = (idx + 1) as f32 / total; + let _ = result_holder.lock().map(|mut holder| { + *holder = OperationResult::ProgressUpdate(op_id, progress, format!("Перемещено {} из {}", idx + 1, total)); + }); + std::thread::sleep(std::time::Duration::from_millis(10)); // небольшая пауза для UI } - - if errors.is_empty() { + + let result = if errors.is_empty() { Ok(format!("Перемещено {} элементов, {} байт", moved_count, total_bytes)) } else { Err(format!("Перемещено {}, ошибки:\n{}", moved_count, errors.join("\n"))) - } + }; + let mut holder = result_holder.lock().unwrap(); + *holder = match result { + Ok(msg) => OperationResult::Success(op_id, msg), + Err(e) => OperationResult::Failure(op_id, e), + }; }); } } - - /// Массовое копирование выбранных файлов/папок + fn perform_copy_multiple(&mut self, sources: Vec, dest: &str) { + if self.active_task_count > 0 || self.rclone.is_none() { + return; + } if let Some(rclone) = self.rclone.clone() { self.state = AppState::Copying; let options = CopyOptions { @@ -301,13 +386,17 @@ impl RcloneUI { no_traverse: false, }; let dest = dest.to_string(); - self.start_background_operation(move || { + let op_id = self.add_operation(format!("Копирование {} элементов", sources.len())); + self.active_task_count += 1; + let result_holder = self.operation_result.clone(); + std::thread::spawn(move || { let _rt = Runtime::new().unwrap(); let mut errors = Vec::new(); let mut copied_count = 0; - - for source in sources { - match operations::sync::copy(&rclone, &source, &dest, &options) { + let total = sources.len() as f32; + + for (idx, source) in sources.iter().enumerate() { + match operations::sync::copy(&rclone, source, &dest, &options) { Ok(_stats) => { copied_count += 1; } @@ -315,21 +404,32 @@ impl RcloneUI { errors.push(format!("{}: {}", source, e)); } } + let progress = (idx + 1) as f32 / total; + let _ = result_holder.lock().map(|mut holder| { + *holder = OperationResult::ProgressUpdate(op_id, progress, format!("Скопировано {} из {}", idx + 1, total)); + }); + std::thread::sleep(std::time::Duration::from_millis(10)); } - - if errors.is_empty() { + + let result = if errors.is_empty() { Ok(format!("Скопировано {} элементов", copied_count)) } else { Err(format!("Скопировано {}, ошибки:\n{}", copied_count, errors.join("\n"))) - } + }; + let mut holder = result_holder.lock().unwrap(); + *holder = match result { + Ok(msg) => OperationResult::Success(op_id, msg), + Err(e) => OperationResult::Failure(op_id, e), + }; }); } } - - /// Удаление нескольких выбранных путей + fn delete_selected_paths(&mut self, paths: Vec) { + if self.active_task_count > 0 || self.rclone.is_none() { + return; + } if let Some(rclone) = self.rclone.clone() { - // Создаём карту: путь -> является ли директорией let mut is_dir_map = std::collections::HashMap::new(); for file in &self.current_files { let full_path = if self.current_path.ends_with(':') { @@ -339,19 +439,22 @@ impl RcloneUI { }; is_dir_map.insert(full_path, file.is_dir); } - - self.start_background_operation(move || { + let op_id = self.add_operation(format!("Удаление {} элементов", paths.len())); + self.active_task_count += 1; + let result_holder = self.operation_result.clone(); + std::thread::spawn(move || { let mut errors = Vec::new(); let mut deleted_count = 0; - - for path in paths { - let is_dir = is_dir_map.get(&path).cloned().unwrap_or(false); + let total = paths.len() as f32; + + for (idx, path) in paths.iter().enumerate() { + let is_dir = is_dir_map.get(path).cloned().unwrap_or(false); let options = DeleteOptions { recursive: is_dir, verbose: false, dry_run: false, }; - match operations::sync::delete(&rclone, &path, &options) { + match operations::sync::delete(&rclone, path, &options) { Ok(stats) => { deleted_count += 1; println!("Удалено: {} ({} элементов)", path, stats.files); @@ -360,86 +463,120 @@ impl RcloneUI { errors.push(format!("{}: {}", path, e)); } } + let progress = (idx + 1) as f32 / total; + let _ = result_holder.lock().map(|mut holder| { + *holder = OperationResult::ProgressUpdate(op_id, progress, format!("Удалено {} из {}", idx + 1, total)); + }); + std::thread::sleep(std::time::Duration::from_millis(10)); } - - if errors.is_empty() { + + let result = if errors.is_empty() { Ok(format!("Успешно удалено {} элементов", deleted_count)) } else { Err(format!("Удалено {} элементов, но возникли ошибки:\n{}", deleted_count, errors.join("\n"))) - } + }; + let mut holder = result_holder.lock().unwrap(); + *holder = match result { + Ok(msg) => OperationResult::Success(op_id, msg), + Err(e) => OperationResult::Failure(op_id, e), + }; }); } } - + + fn add_operation(&mut self, description: String) -> u32 { + let id = self.next_op_id; + self.next_op_id += 1; + self.active_operations.push(Operation { + id, + description, + progress: 0.0, + status: "Начато".to_string(), + start_time: Instant::now(), + }); + id + } + fn create_remote_with_warning(&mut self, name: &str, r#type: &str) { self.pending_remote_creation = Some((name.to_string(), r#type.to_string())); self.show_browser_warning = true; } - + fn execute_remote_creation(&mut self, name: &str, r#type: &str) { + if self.active_task_count > 0 || self.rclone.is_none() { + return; + } if let Some(rclone) = self.rclone.clone() { let name = name.to_string(); let r#type = r#type.to_string(); let config = std::collections::HashMap::new(); - self.start_background_operation(move || { - operations::remotes::create(&rclone, &name, &r#type, &config) + let op_id = self.add_operation(format!("Создание хранилища '{}'", name)); + self.active_task_count += 1; + let result_holder = self.operation_result.clone(); + std::thread::spawn(move || { + let _ = result_holder.lock().map(|mut holder| { + *holder = OperationResult::ProgressUpdate(op_id, 0.5, "Открытие браузера для авторизации...".to_string()); + }); + let result = operations::remotes::create(&rclone, &name, &r#type, &config) .map(|_| format!("Хранилище '{}' создано", name)) - .map_err(|e| format!("Ошибка создания: {}", e)) + .map_err(|e| format!("Ошибка создания: {}", e)); + let mut holder = result_holder.lock().unwrap(); + *holder = match result { + Ok(msg) => OperationResult::Success(op_id, msg), + Err(e) => OperationResult::Failure(op_id, e), + }; }); } } - - fn search_files(&mut self, pattern: &str) { - if let Some(rclone) = &self.rclone { - let options = FindOptions { - recursive: true, - max_results: 100, - }; - match operations::search::by_name(rclone, &self.current_path, pattern, &options) { - Ok(results) => self.search_results = results, - Err(e) => self.error_message = Some(e), - } - } - } - + fn validate_remote_name(name: &str) -> bool { if name.is_empty() || name.starts_with(' ') || name.starts_with('-') || name.ends_with(' ') { return false; } name.chars().all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' || ch == '.' || ch == '+' || ch == '@' || ch == ' ') } - + fn format_size(bytes: u64) -> String { const KB: u64 = 1024; const MB: u64 = KB * 1024; const GB: u64 = MB * 1024; - if bytes >= GB { format!("{:.2} ГБ", bytes as f64 / GB as f64) } - else if bytes >= MB { format!("{:.2} МБ", bytes as f64 / MB as f64) } - else if bytes >= KB { format!("{:.2} КБ", bytes as f64 / KB as f64) } - else { format!("{} Б", bytes) } + if bytes >= GB { + format!("{:.2} ГБ", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.2} МБ", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.2} КБ", bytes as f64 / KB as f64) + } else { + format!("{} Б", bytes) + } } - + + #[allow(dead_code)] fn format_speed(speed: f64) -> String { - if speed < 1024.0 { format!("{:.0} Б/с", speed) } - else if speed < 1024.0 * 1024.0 { format!("{:.1} КБ/с", speed / 1024.0) } - else if speed < 1024.0 * 1024.0 * 1024.0 { format!("{:.1} МБ/с", speed / (1024.0 * 1024.0)) } - else { format!("{:.2} ГБ/с", speed / (1024.0 * 1024.0 * 1024.0)) } + if speed < 1024.0 { + format!("{:.0} Б/с", speed) + } else if speed < 1024.0 * 1024.0 { + format!("{:.1} КБ/с", speed / 1024.0) + } else if speed < 1024.0 * 1024.0 * 1024.0 { + format!("{:.1} МБ/с", speed / (1024.0 * 1024.0)) + } else { + format!("{:.2} ГБ/с", speed / (1024.0 * 1024.0 * 1024.0)) + } } } impl eframe::App for RcloneUI { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.poll_rclone_init(); self.poll_background_operation(); - - if matches!(self.state, AppState::Initializing) && self.rclone.is_none() { - self.init_rclone(); - } - + // Верхняя панель меню egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { egui::menu::bar(ui, |ui| { ui.menu_button("Файл", |ui| { - if ui.button("Выход").clicked() { std::process::exit(0); } + if ui.button("Выход").clicked() { + std::process::exit(0); + } }); ui.menu_button("Хранилища", |ui| { if ui.button("Добавить новое хранилище").clicked() { @@ -462,29 +599,36 @@ impl eframe::App for RcloneUI { }); }); ui.separator(); - // Индикатор состояния с анимацией - if self.background_working { + if self.active_task_count > 0 { ui.horizontal(|ui| { ui.add(Spinner::new().size(16.0)); - ui.colored_label(egui::Color32::YELLOW, " Работа в фоне..."); + ui.colored_label(egui::Color32::YELLOW, format!(" Работа в фоне... ({} задач)", self.active_task_count)); }); } else { match &self.state { - AppState::Ready => { ui.colored_label(Color32::GREEN, "● Готов"); } - AppState::Initializing => { ui.colored_label(Color32::YELLOW, "● Инициализация..."); } - AppState::Error(_) => { ui.colored_label(Color32::RED, "● Ошибка"); } - _ => { ui.colored_label(Color32::BLUE, "● Занят"); } + AppState::Ready => { + ui.colored_label(Color32::GREEN, "● Готов"); + } + AppState::Initializing => { + ui.colored_label(Color32::YELLOW, "● Инициализация..."); + } + AppState::Error(_) => { + ui.colored_label(Color32::RED, "● Ошибка"); + } + _ => { + ui.colored_label(Color32::BLUE, "● Занят"); + } } - }; + } if let Some(rclone) = &self.rclone { ui.separator(); ui.label(format!("rclone: {}", rclone.get_rclone_path().display())); } }); }); - + // Левая панель со списком хранилищ - egui::SidePanel::left("remotes_panel") + SidePanel::left("remotes_panel") .default_width(220.0) .show(ctx, |ui| { ui.heading("Хранилища"); @@ -499,7 +643,8 @@ impl eframe::App for RcloneUI { } else { for remote in &remotes { ui.horizontal(|ui| { - if ui.button(format!("📡 {}", remote.name)).clicked() { + let btn = ui.button(format!("📡 {}", remote.name)); + if btn.clicked() && self.active_task_count == 0 { self.pending_load_path = Some(format!("{}:", remote.name)); } if ui.button("🗑️").on_hover_text("Удалить хранилище").clicked() { @@ -512,7 +657,30 @@ impl eframe::App for RcloneUI { } }); }); - + + // Правая панель с операциями + SidePanel::right("operations_panel") + .default_width(280.0) + .show(ctx, |ui| { + ui.heading("Операции"); + ui.separator(); + if self.active_operations.is_empty() { + ui.colored_label(Color32::GRAY, "Нет активных операций"); + } else { + ScrollArea::vertical().show(ui, |ui| { + for op in &self.active_operations { + ui.group(|ui| { + ui.label(&op.description); + ui.add(ProgressBar::new(op.progress).text(format!("{:.0}%", op.progress * 100.0))); + ui.colored_label(Color32::LIGHT_BLUE, &op.status); + ui.label(format!("Время: {:.0} сек", op.start_time.elapsed().as_secs())); + }); + ui.add_space(5.0); + } + }); + } + }); + // Центральная панель CentralPanel::default().show(ctx, |ui| { let current_path = self.current_path.clone(); @@ -525,39 +693,35 @@ impl eframe::App for RcloneUI { ui.label("🔍 Поиск:"); if ui.text_edit_singleline(&mut new_search).changed() { self.search_pattern = new_search.clone(); - if !self.search_pattern.is_empty() && !self.current_path.is_empty() { + if !self.search_pattern.is_empty() && !self.current_path.is_empty() && self.active_task_count == 0 { self.pending_search_pattern = Some(self.search_pattern.clone()); } else { self.search_results.clear(); } } - if ui.button("🔄 Обновить").clicked() && !self.current_path.is_empty() { + if ui.button("🔄 Обновить").clicked() && !self.current_path.is_empty() && self.active_task_count == 0 { self.pending_load_path = Some(self.current_path.clone()); } }); ui.separator(); - - // Анимированная загрузка в центральной панели - if self.background_working { + + if self.active_task_count > 0 { ui.centered_and_justified(|ui| { ui.add(Spinner::new().size(64.0)); ui.add_space(10.0); - ui.colored_label( - egui::Color32::LIGHT_BLUE, - "⟳ Выполняется операция... Пожалуйста, подождите." - ); + ui.colored_label(egui::Color32::LIGHT_BLUE, "⟳ Выполняется операция... Пожалуйста, подождите."); }); - ctx.request_repaint_after(Duration::from_millis(16)); + ctx.request_repaint_after(Duration::from_millis(50)); return; } - + if let AppState::Error(msg) = &self.state { ui.centered_and_justified(|ui| { ui.colored_label(Color32::RED, format!("Ошибка: {}", msg)); }); return; } - + if !self.search_results.is_empty() { let search_results = self.search_results.clone(); let current_path = self.current_path.clone(); @@ -566,7 +730,7 @@ impl eframe::App for RcloneUI { for file in &search_results { ui.horizontal(|ui| { ui.label(file.icon()); - if ui.link(&file.name).clicked() && file.is_dir { + if ui.link(&file.name).clicked() && file.is_dir && self.active_task_count == 0 { let new_path = if current_path.ends_with(':') { format!("{}{}", current_path, file.name) } else { @@ -582,13 +746,12 @@ impl eframe::App for RcloneUI { let current_files = self.current_files.clone(); let current_path = self.current_path.clone(); ScrollArea::vertical().show(ui, |ui| { - if current_path.contains('/') && ui.button("📁 .. (Родительская папка)").clicked() { + if current_path.contains('/') && ui.button("📁 .. (Родительская папка)").clicked() && self.active_task_count == 0 { let parent = current_path.rsplit_once('/').map(|(p, _)| p).unwrap_or(""); self.pending_load_path = Some(parent.to_string()); } ui.heading("Файлы и папки"); for file in ¤t_files { - // Формируем полный путь rclone (с именем удалённого хранилища) let full_path = if current_path.ends_with(':') { format!("{}{}", current_path, file.name) } else { @@ -596,7 +759,7 @@ impl eframe::App for RcloneUI { }; let selected = self.selected_paths.contains(&full_path); ui.horizontal(|ui| { - if ui.selectable_label(selected, file.icon()).clicked() { + if ui.selectable_label(selected, file.icon()).clicked() && self.active_task_count == 0 { if selected { self.selected_paths.retain(|p| p != &full_path); } else { @@ -604,7 +767,7 @@ impl eframe::App for RcloneUI { } } if file.is_dir { - if ui.link(&file.name).clicked() { + if ui.link(&file.name).clicked() && self.active_task_count == 0 { let new_path = if current_path.ends_with(':') { format!("{}{}", current_path, file.name) } else { @@ -628,9 +791,9 @@ impl eframe::App for RcloneUI { } }); } - + ui.separator(); - if !self.selected_paths.is_empty() { + if !self.selected_paths.is_empty() && self.active_task_count == 0 { ui.horizontal(|ui| { ui.label(format!("Выбрано: {}", self.selected_paths.len())); if ui.button("📋 Копировать").clicked() { @@ -649,16 +812,20 @@ impl eframe::App for RcloneUI { }); } }); - + // Обработка отложенных действий if let Some(path) = self.pending_load_path.take() { + self.current_path = path.clone(); + self.search_results.clear(); + self.search_pattern.clear(); + self.selected_paths.clear(); self.load_files(&path); } if let Some(pattern) = self.pending_search_pattern.take() { self.search_files(&pattern); } - - // Диалог передачи файлов (поддерживает несколько источников) + + // Диалог передачи файлов (копирование/перемещение) if self.show_transfer_dialog { let source_count = self.transfer_source_list.len(); let dest = self.transfer_dest.clone(); @@ -707,108 +874,130 @@ impl eframe::App for RcloneUI { }); }); } - + // Диалог добавления хранилища if self.show_new_remote_dialog { let mut name = self.new_remote_name.clone(); let mut typ = self.new_remote_type.clone(); let available_types = self.available_remote_types.clone(); - Window::new("Добавление хранилища").collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.label("Имя хранилища:"); - if ui.text_edit_singleline(&mut name).changed() { - self.new_remote_name = name; - } - ui.label("Тип хранилища:"); - egui::ComboBox::from_label("").selected_text(&typ).show_ui(ui, |ui| { - for t in &available_types { - if ui.selectable_value(&mut typ, t.clone(), t).clicked() { - self.new_remote_type = typ.clone(); - } + Window::new("Добавление хранилища") + .collapsible(false) + .resizable(false) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label("Имя хранилища:"); + if ui.text_edit_singleline(&mut name).changed() { + self.new_remote_name = name; } - }); - ui.label("⚠️ Некоторые типы (Google Drive, Dropbox и др.) откроют браузер для авторизации."); - let name_clone = self.new_remote_name.clone(); - let type_clone = self.new_remote_type.clone(); - ui.horizontal(|ui| { - if ui.button("✅ Создать").clicked() { - if !Self::validate_remote_name(&name_clone) { - self.error_message = Some("Недопустимое имя хранилища".to_string()); - } else if type_clone.is_empty() { - self.error_message = Some("Выберите тип".to_string()); - } else { - self.create_remote_with_warning(&name_clone, &type_clone); + ui.label("Тип хранилища:"); + egui::ComboBox::from_label("") + .selected_text(&typ) + .show_ui(ui, |ui| { + for t in &available_types { + if ui.selectable_value(&mut typ, t.clone(), t).clicked() { + self.new_remote_type = typ.clone(); + } + } + }); + ui.label("⚠️ Некоторые типы (Google Drive, Dropbox и др.) откроют браузер для авторизации."); + let name_clone = self.new_remote_name.clone(); + let type_clone = self.new_remote_type.clone(); + ui.horizontal(|ui| { + if ui.button("✅ Создать").clicked() { + if !Self::validate_remote_name(&name_clone) { + self.error_message = Some("Недопустимое имя хранилища".to_string()); + } else if type_clone.is_empty() { + self.error_message = Some("Выберите тип".to_string()); + } else { + self.create_remote_with_warning(&name_clone, &type_clone); + self.show_new_remote_dialog = false; + } + } + if ui.button("❌ Отмена").clicked() { self.show_new_remote_dialog = false; + self.new_remote_name.clear(); + self.new_remote_type.clear(); } - } - if ui.button("❌ Отмена").clicked() { - self.show_new_remote_dialog = false; - self.new_remote_name.clear(); - self.new_remote_type.clear(); - } + }); }); - }); } - + // Диалог предупреждения о браузере if self.show_browser_warning { - Window::new("Внимание").collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.colored_label(Color32::YELLOW, "🌐 Будет открыт браузер для авторизации"); - ui.label("После завершения авторизации в браузере вернитесь в приложение."); - ui.label("Операция выполняется в фоне, интерфейс не зависнет."); - ui.separator(); - if ui.button("Продолжить").clicked() { - if let Some((name, typ)) = self.pending_remote_creation.take() { - self.execute_remote_creation(&name, &typ); + Window::new("Внимание") + .collapsible(false) + .resizable(false) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.colored_label(Color32::YELLOW, "🌐 Будет открыт браузер для авторизации"); + ui.label("После завершения авторизации в браузере вернитесь в приложение."); + ui.label("Операция выполняется в фоне, интерфейс не зависнет."); + ui.separator(); + if ui.button("Продолжить").clicked() { + if let Some((name, typ)) = self.pending_remote_creation.take() { + self.execute_remote_creation(&name, &typ); + } + self.show_browser_warning = false; } - self.show_browser_warning = false; - } - if ui.button("Отмена").clicked() { - self.pending_remote_creation = None; - self.show_browser_warning = false; - } - }); + if ui.button("Отмена").clicked() { + self.pending_remote_creation = None; + self.show_browser_warning = false; + } + }); } - + // Диалог подтверждения удаления хранилища if self.show_delete_remote_dialog { let remote_name = self.remote_to_delete.clone(); - Window::new("Удаление хранилища").collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - if let Some(ref name) = remote_name { - ui.label(format!("Удалить хранилище '{}'?", name)); - ui.label("Это действие не удаляет данные, только конфигурацию."); - let name_clone = name.clone(); - ui.horizontal(|ui| { - if ui.button("✅ Удалить").clicked() { - self.delete_remote(&name_clone); - self.show_delete_remote_dialog = false; - self.remote_to_delete = None; - } - if ui.button("❌ Отмена").clicked() { - self.show_delete_remote_dialog = false; - self.remote_to_delete = None; - } - }); - } else { - self.show_delete_remote_dialog = false; - } - }); + Window::new("Удаление хранилища") + .collapsible(false) + .resizable(false) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + if let Some(ref name) = remote_name { + ui.label(format!("Удалить хранилище '{}'?", name)); + ui.label("Это действие не удаляет данные, только конфигурацию."); + let name_clone = name.clone(); + ui.horizontal(|ui| { + if ui.button("✅ Удалить").clicked() { + self.delete_remote(&name_clone); + self.show_delete_remote_dialog = false; + self.remote_to_delete = None; + } + if ui.button("❌ Отмена").clicked() { + self.show_delete_remote_dialog = false; + self.remote_to_delete = None; + } + }); + } else { + self.show_delete_remote_dialog = false; + } + }); } - - // Диалог ошибок + + // Диалог ошибок / уведомлений if let Some(error) = &self.error_message { let error_clone = error.clone(); - Window::new("Ошибка").collapsible(false).resizable(false).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { - ui.colored_label(Color32::RED, "⚠️ Произошла ошибка:"); - ui.label(&error_clone); - if ui.button("OK").clicked() { - self.error_message = None; - if let AppState::Error(_) = &self.state { - self.state = AppState::Ready; + Window::new("Сообщение") + .collapsible(false) + .resizable(false) + .anchor(Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + if error_clone.starts_with("✅") || error_clone.contains("успеш") || error_clone.contains("скопировано") { + ui.colored_label(Color32::GREEN, "✅ Успешно:"); + } else { + ui.colored_label(Color32::RED, "⚠️ Произошла ошибка:"); } - } - }); + ui.label(&error_clone); + if ui.button("OK").clicked() { + self.error_message = None; + if let AppState::Error(_) = &self.state { + self.state = AppState::Ready; + } + } + }); } - - ctx.request_repaint(); + + ctx.request_repaint_after(Duration::from_millis(100)); } }